"""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 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) from astrbot.cli.banner import print_logo print_logo() 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("[模式] 完整模式(含本地 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