From ca4923dec60dc8c71e3dd5d83689a3182b5d348f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 2 Apr 2026 21:28:37 +0800 Subject: [PATCH] refactor: remove TUI from cmd_run --- astrbot/cli/commands/cmd_run.py | 345 +++++++++++++++++++++++++++++++- 1 file changed, 336 insertions(+), 9 deletions(-) diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index d38e77e65..07bea9582 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -1,14 +1,90 @@ +"""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.core.utils.astrbot_path import get_astrbot_root +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: @@ -16,7 +92,11 @@ async def run_astrbot(astrbot_root: Path) -> None: from astrbot.core import LogBroker, LogManager, db_helper, logger from astrbot.core.initial_loader import InitialLoader - await DashboardManager().ensure_installed(astrbot_root) + 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) @@ -28,33 +108,279 @@ async def run_astrbot(astrbot_root: Path) -> None: @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, port: str) -> None: +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: - os.environ["ASTRBOT_CLI"] = "1" - astrbot_root = Path(get_astrbot_root()) + if debug: + log_level = "DEBUG" - if not (astrbot_root / "data").exists(): + # --- 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)) - if port: - os.environ["DASHBOARD_PORT"] = port + # 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(): - asyncio.run(run_astrbot(astrbot_root)) + + 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("AstrBot is running... (streaming logs)") + if backend_only: + click.echo("Dashboard: https://dash.astrbot.men/") + click.echo("Backend: localhost or based on https") + + asyncio.run(run_with_logging()) except KeyboardInterrupt: click.echo("AstrBot has been shut down.") except Timeout: @@ -62,4 +388,5 @@ def run(reload: bool, port: str) -> None: "Cannot acquire lock file. Please check if another instance is running" ) except Exception as e: + # Keep original traceback visible for diagnostics raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")