Files
AstrBot/astrbot/cli/commands/cmd_run.py
LIghtJUNction dcf84e6726 feat: enhance CLI prompts and fix frontend 401 handling
- cmd_conf: show password rules before setting, display length after input
- cmd_init/cmd_run: add Chinese prompts and HTTPS security warnings
- openai_source/gemini_source: add missing provider adapter import
- ConsoleDisplayer: add missing resolveApiUrl import for SSE
- WelcomePage: show user-friendly message for 401 unauthorized errors
2026-04-10 21:58:52 +08:00

422 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AstrBot Run
Environment Variables Used in Project:
Core:
- `ASTRBOT_ROOT`: AstrBot root directory path.
- `ASTRBOT_LOG_LEVEL`: Log level (e.g. INFO, DEBUG).
- `ASTRBOT_CLI`: Flag indicating execution via CLI.
- `ASTRBOT_DESKTOP_CLIENT`: Flag indicating execution via desktop client.
- `ASTRBOT_SYSTEMD`: Flag indicating execution via systemd service.
- `ASTRBOT_RELOAD`: Enable plugin auto-reload (set to "1").
- `ASTRBOT_DISABLE_METRICS`: Disable metrics upload (set to "1").
- `TESTING`: Enable testing mode.
- `DEMO_MODE`: Enable demo mode.
- `PYTHON`: Python executable path override (for local code execution).
Dashboard / Backend:
- `ASTRBOT_DASHBOARD_ENABLE`: Enable/Disable Dashboard.
- `ASTRBOT_HOST`: Dashboard bind host.
- `ASTRBOT_PORT`: Dashboard bind port.
SSL (AstrBot-standard names):
- `ASTRBOT_SSL_ENABLE`: Enable SSL for API.
- `ASTRBOT_SSL_CERT`: SSL Certificate path for backend.
- `ASTRBOT_SSL_KEY`: SSL Key path for backend.
- `ASTRBOT_SSL_CA_CERTS`: SSL CA Certs path for backend.
Network:
- `http_proxy` / `https_proxy`: Proxy URL.
- `no_proxy`: No proxy list.
Internationalization:
- `ASTRBOT_CLI_LANG`: CLI interface language (zh/en).
Integrations:
- `DASHSCOPE_API_KEY`: Alibaba DashScope API Key (for Rerank).
- `COZE_API_KEY` / `COZE_BOT_ID`: Coze integration.
- `BAY_DATA_DIR`: Computer Use data directory.
Platform Specific:
- `TEST_MODE`: Test mode for QQOfficial.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import traceback
from pathlib import Path
import click
from dotenv import load_dotenv
from filelock import FileLock, Timeout
from astrbot.cli.utils import DashboardManager
from astrbot.runtime_bootstrap import initialize_runtime_bootstrap
# Python version check: require 3.12 or 3.13
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
sys.exit(1)
# Regular expression to find bash-like parameter expansions:
# ${VAR:-default} or ${VAR}
_PARAM_EXPAND_RE = re.compile(r"\$\{([^}:]+?)(:-([^}]*))?\}")
def _expand_parameter(
match: re.Match,
env: dict[str, str],
local: dict[str, str],
) -> str:
"""Helper to expand a single ${VAR:-default} or ${VAR} occurrence.
Precedence:
1. local dict (parsed from the same file, earlier entries)
2. environment variables
3. default provided in the expansion (if any)
4. empty string
"""
var = match.group(1)
default = match.group(3) if match.group(3) is not None else ""
# Prefer 'local' parsed values first
if var in local and local[var] != "":
return local[var]
val = env.get(var, "")
if val != "":
return val
return default
async def run_astrbot(astrbot_root: Path) -> None:
"""Run AstrBot"""
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
if (
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
db = db_helper
core_lifecycle = InitialLoader(db, log_broker)
await core_lifecycle.start()
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option("--root", help="AstrBot root directory", required=False, type=str)
@click.option(
"--service-config",
"-c",
help="Service configuration file path (supports ${VAR:-default} style expansion)",
required=False,
type=str,
)
@click.option(
"--backend-only",
"-b",
is_flag=True,
default=False,
help="Disable WebUI, run backend only",
)
@click.option(
"--log-level",
"-l",
help="Log level",
required=False,
type=str,
default="INFO",
)
@click.option(
"--ssl-cert",
help="SSL certificate file path for backend (preferred env name: ASTRBOT_SSL_CERT)",
required=False,
type=str,
)
@click.option(
"--ssl-key",
help="SSL private key file path for backend (preferred env name: ASTRBOT_SSL_KEY)",
required=False,
type=str,
)
@click.option(
"--ssl-ca",
help="SSL CA certificates file path for backend (preferred env name: ASTRBOT_SSL_CA_CERTS)",
required=False,
type=str,
)
@click.option("--debug", is_flag=True, help="Enable debug mode")
@click.command()
def run(
reload: bool,
host: str,
port: str,
root: str,
service_config: str,
backend_only: bool,
log_level: str,
ssl_cert: str,
ssl_key: str,
ssl_ca: str,
debug: bool,
) -> None:
"""Run AstrBot"""
initialize_runtime_bootstrap()
try:
if debug:
log_level = "DEBUG"
# --- Step 1: Resolve service-config path (if provided). We'll treat it as a .env file later. ---
svc_path: Path | None = None
if service_config:
candidate = Path(service_config)
if not candidate.exists():
# Try to expand user and resolve
candidate = Path(os.path.expanduser(service_config))
if candidate.exists():
svc_path = candidate
# NOTE:
# Loading of common .env files (CWD/.env, packaged project .env, ASTRBOT_ROOT/.env)
# has been moved to astrbot.core.utils.astrbot_path during import-time to avoid
# early-initialization ordering issues. Those files are loaded there using
# `override=False` so they do not clobber environment variables provided by the
# systemd unit or the caller.
#
# Here we only load an explicit service-config file (if given). Service-config
# should be able to override the common .env files, but CLI-provided values must
# still win; the CLI will set/overwrite corresponding environment variables
# below after this load.
if svc_path and svc_path.exists():
# Load service-config as an env file and allow it to override previously-loaded
# .env values (those were loaded by astrbot_path). CLI variables are applied
# after this point and will take precedence.
load_dotenv(dotenv_path=str(svc_path), override=True)
# Mark CLI execution
os.environ["ASTRBOT_CLI"] = "1"
from astrbot.core.utils.astrbot_path import astrbot_paths
# Resolve astrbot_root with the following precedence:
# 1. CLI --root parameter (local variable `root`)
# 2. ASTRBOT_ROOT environment variable (possibly from .env or parsed service config)
# 3. packaged default astrbot_paths.root
if root:
os.environ["ASTRBOT_ROOT"] = root
astrbot_root = Path(root)
elif os.environ.get("ASTRBOT_ROOT"):
astrbot_root = Path(os.environ["ASTRBOT_ROOT"])
else:
astrbot_root = astrbot_paths.root
if not astrbot_paths.is_root:
raise click.ClickException(
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
# Ensure ASTRBOT_ROOT env var is set to the resolved root (without overriding a CLI-provided root value above)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
# Host/Port precedence: CLI args > parsed service config/env/.env > defaults.
if port is not None:
os.environ["ASTRBOT_PORT"] = port
if host is not None:
os.environ["ASTRBOT_HOST"] = host
# CLI-provided SSL paths should set backend-standard env names.
if ssl_cert is not None:
os.environ["ASTRBOT_SSL_CERT"] = ssl_cert
if ssl_key is not None:
os.environ["ASTRBOT_SSL_KEY"] = ssl_key
if ssl_ca is not None:
os.environ["ASTRBOT_SSL_CA_CERTS"] = ssl_ca
# Dashboard enable is derived from CLI flag (--backend-only). CLI decision should win.
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
if reload:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if debug:
keys_to_print = [
"ASTRBOT_ROOT",
"ASTRBOT_LOG_LEVEL",
"ASTRBOT_CLI",
"ASTRBOT_DESKTOP_CLIENT",
"ASTRBOT_SYSTEMD",
"ASTRBOT_RELOAD",
"ASTRBOT_DISABLE_METRICS",
"TESTING",
"DEMO_MODE",
"PYTHON",
"ASTRBOT_DASHBOARD_ENABLE",
"DASHBOARD_ENABLE",
"ASTRBOT_HOST",
"DASHBOARD_HOST",
"ASTRBOT_PORT",
"DASHBOARD_PORT",
# Dashboard SSL (legacy)
"ASTRBOT_SSL_ENABLE",
"DASHBOARD_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"DASHBOARD_SSL_CERT",
"ASTRBOT_SSL_KEY",
"DASHBOARD_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"DASHBOARD_SSL_CA_CERTS",
# Backend-standard SSL (preferred)
"ASTRBOT_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"ASTRBOT_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"http_proxy",
"https_proxy",
"no_proxy",
"DASHSCOPE_API_KEY",
"COZE_API_KEY",
"COZE_BOT_ID",
"BAY_DATA_DIR",
"TEST_MODE",
]
click.secho("\n[Debug Mode] Environment Variables:", fg="yellow", bold=True)
for key in keys_to_print:
if key in os.environ:
val = os.environ[key]
if "KEY" in key or "PASSWORD" in key or "SECRET" in key:
if len(val) > 8:
val = val[:4] + "****" + val[-4:]
else:
val = "****"
click.echo(f" {click.style(key, fg='cyan')}: {val}")
if svc_path:
click.echo(
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}",
)
click.echo("")
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():
async def run_with_logging() -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
if (
os.environ.get(
"ASTRBOT_DASHBOARD_ENABLE",
os.environ.get("DASHBOARD_ENABLE"),
)
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
# Register a stdout subscriber for real-time log streaming
log_queue = log_broker.register()
db = db_helper
initial_loader = InitialLoader(db, log_broker)
# Start a task to stream logs to stdout
async def stream_logs() -> None:
"""Stream logs from LogBroker to stdout."""
while True:
try:
log_entry = await asyncio.wait_for(
log_queue.get(),
timeout=0.5,
)
# Format: [LEVEL] message
level = log_entry.get("level_name", "INFO")
message = log_entry.get("message", "")
if message:
level_color = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}.get(level, "white")
click.secho(
f"[{level}]",
fg=level_color,
bold=False,
nl=False,
)
click.echo(f" {message}")
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
# Start streaming task
stream_task = asyncio.create_task(stream_logs())
try:
await initial_loader.start()
finally:
stream_task.cancel()
try:
await stream_task
except asyncio.CancelledError:
pass
click.echo()
click.echo("=" * 60)
click.echo("AstrBot 启动中...")
click.echo("=" * 60)
click.echo()
if backend_only:
click.echo("[模式] 仅后端模式(无本地 Dashboard")
click.echo()
click.echo("[提示] 可以通过以下方式访问 WebUI")
click.echo(" - 使用远程服务器的在线 Dashboard")
click.echo(" - 地址: http://服务器IP:6185")
click.echo()
else:
dashboard_url = f"http://{host or 'localhost'}:{port or '6185'}"
click.echo(f"[模式] 完整模式(含本地 Dashboard")
click.echo()
click.echo(f"[Dashboard] 请访问: {dashboard_url}")
click.echo()
click.echo("!" * 60)
click.echo("安全提示:")
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
click.echo(" 不支持远程 + HTTP 后端(不安全)")
click.echo("!" * 60)
click.echo()
click.echo("正在启动服务...(日志输出中)")
click.echo()
asyncio.run(run_with_logging())
except KeyboardInterrupt:
click.echo("AstrBot has been shut down.")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running",
) from None
except Exception as e:
# Keep original traceback visible for diagnostics
raise click.ClickException(
f"Runtime error: {e}\n{traceback.format_exc()}",
) from e