From e8c234f0cf50c3f6a495fd70fe05c9f0687010c3 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 18 Mar 2026 18:21:49 +0800 Subject: [PATCH] feat: improve cli admin setup and api startup logs --- astrbot/cli/commands/cmd_conf.py | 69 +++++++++++++++++++++++++- astrbot/cli/commands/cmd_init.py | 83 ++++++++++++++++++++++++++++++-- astrbot/dashboard/server.py | 42 +++++++++------- 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index 5f3250913..0dbc15292 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -11,6 +11,11 @@ from astrbot.core.utils.astrbot_path import astrbot_paths from ..utils import check_astrbot_root +def hash_dashboard_password(value: str) -> str: + """Hash Dashboard password for storage.""" + return hashlib.md5(value.encode()).hexdigest() + + def _validate_log_level(value: str) -> str: """Validate log level""" value = value.upper() @@ -43,7 +48,7 @@ def _validate_dashboard_password(value: str) -> str: """Validate Dashboard password""" if not value: raise click.ClickException("Password cannot be empty") - return hashlib.md5(value.encode()).hexdigest() + return hash_dashboard_password(value) def _validate_timezone(value: str) -> str: @@ -110,6 +115,11 @@ def _save_config(config: dict[str, Any]) -> None: ) +def ensure_config_file() -> dict[str, Any]: + """Ensure config file exists and return parsed config.""" + return _load_config() + + def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None: """Set a value in a nested dictionary""" parts = path.split(".") @@ -132,6 +142,34 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any: return obj +def prompt_dashboard_password(prompt: str = "Dashboard password") -> str: + """Prompt for dashboard password with confirmation.""" + password = click.prompt( + prompt, + hide_input=True, + confirmation_prompt=True, + type=str, + ) + return _validate_dashboard_password(password) + + +def set_dashboard_credentials( + config: dict[str, Any], + *, + username: str | None = None, + password_hash: str | None = None, +) -> None: + """Update dashboard credentials in config.""" + if username is not None: + _set_nested_item( + config, + "dashboard.username", + _validate_dashboard_username(username), + ) + if password_hash is not None: + _set_nested_item(config, "dashboard.password", password_hash) + + @click.group(name="conf") def conf() -> None: """Configuration management commands @@ -213,3 +251,32 @@ def get_config(key: str | None = None) -> None: click.echo(f" {key}: {value}") except (KeyError, TypeError): pass + + +@conf.command(name="password") +@click.option("-u", "--username", type=str, help="Update dashboard username as well") +@click.option( + "-p", + "--password", + type=str, + help="Set dashboard password directly without interactive prompt", +) +def set_dashboard_password(username: str | None, password: str | None) -> None: + """Interactively manage dashboard password.""" + config = _load_config() + + password_hash = ( + _validate_dashboard_password(password) + if password is not None + else prompt_dashboard_password() + ) + set_dashboard_credentials( + config, + username=username.strip() if username is not None else None, + password_hash=password_hash, + ) + _save_config(config) + + if username is not None: + click.echo(f"Dashboard username updated: {username.strip()}") + click.echo("Dashboard password updated.") diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py index 9d1fee029..8d470f530 100644 --- a/astrbot/cli/commands/cmd_init.py +++ b/astrbot/cli/commands/cmd_init.py @@ -1,17 +1,30 @@ import asyncio +import json import os from pathlib import Path import click from filelock import FileLock, Timeout +from astrbot.core.config.default import DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import astrbot_paths from ..utils import check_dashboard +from .cmd_conf import ( + _validate_dashboard_password, + ensure_config_file, + prompt_dashboard_password, + set_dashboard_credentials, +) async def initialize_astrbot( - astrbot_root: Path, *, yes: bool, backend_only: bool + astrbot_root: Path, + *, + yes: bool, + backend_only: bool, + admin_username: str | None, + admin_password: str | None, ) -> None: """Execute AstrBot initialization logic""" dot_astrbot = astrbot_root / ".astrbot" @@ -38,6 +51,44 @@ async def initialize_astrbot( f"{'Created' if not path.exists() else f'{name} Directory exists'}: {path}" ) + config_path = astrbot_root / "data" / "cmd_config.json" + if not config_path.exists(): + config_path.write_text( + json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + click.echo(f"Created config file: {config_path}") + + if admin_password and not admin_username: + raise click.ClickException( + "--admin-password requires --admin-username to be provided" + ) + + if admin_username: + password_hash = ( + _validate_dashboard_password(admin_password) + if admin_password is not None + else None + ) + if password_hash is None: + if yes or os.environ.get("ASTRBOT_SYSTEMD") == "1": + raise click.ClickException( + "Non-interactive init requires --admin-password when --admin-username is set" + ) + password_hash = prompt_dashboard_password("Dashboard admin password") + + config = ensure_config_file() + set_dashboard_credentials( + config, + username=admin_username.strip(), + password_hash=password_hash, + ) + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + click.echo(f"Configured dashboard admin username: {admin_username.strip()}") + if not backend_only and ( yes or click.confirm( @@ -57,8 +108,26 @@ async def initialize_astrbot( @click.command() @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts") @click.option("--backend-only", "-b", is_flag=True, help="Only initialize the backend") -@click.option("--backup", help="Initialize from backup file", type=str) -def init(yes: bool, backend_only: bool, backup: str | None) -> None: +@click.option("--backup", "-f", help="Initialize from backup file", type=str) +@click.option( + "-u", + "--admin-username", + type=str, + help="Set dashboard admin username during initialization", +) +@click.option( + "-p", + "--admin-password", + type=str, + help="Set dashboard admin password during initialization without prompting", +) +def init( + yes: bool, + backend_only: bool, + backup: str | None, + admin_username: str | None, + admin_password: str | None, +) -> None: """Initialize AstrBot""" click.echo("Initializing AstrBot...") @@ -72,7 +141,13 @@ def init(yes: bool, backend_only: bool, backup: str | None) -> None: try: with lock.acquire(): asyncio.run( - initialize_astrbot(astrbot_root, yes=yes, backend_only=backend_only) + initialize_astrbot( + astrbot_root, + yes=yes, + backend_only=backend_only, + admin_username=admin_username, + admin_password=admin_password, + ) ) if backup: diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index d815d008a..2b6040aa3 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -410,21 +410,19 @@ class AstrBotDashboard: ) scheme = "https" if ssl_enable else "http" - display_host = f"[{host}]" if ":" in host else host + binds: list[str] = [self._build_bind(host, port)] + if host == "::" and platform.system() in ("Windows", "Darwin"): + binds.append(self._build_bind("0.0.0.0", port)) if self.enable_webui: logger.info( - "正在启动 WebUI + API, 监听地址: %s://%s:%s", - scheme, - display_host, - port, + "正在启动 WebUI + API, 监听: %s", + ", ".join(f"{scheme}://{bind}" for bind in binds), ) else: logger.info( - "正在启动 API Server (WebUI 已分离), 监听地址: %s://%s:%s", - scheme, - display_host, - port, + "正在启动 API Server (WebUI 已分离), 监听: %s", + ", ".join(f"{scheme}://{bind}" for bind in binds), ) check_hosts = {host} @@ -435,14 +433,10 @@ class AstrBotDashboard: info = self.get_process_using_port(port) raise RuntimeError(f"端口 {port} 已被占用\n{info}") - if self.enable_webui: - self._print_access_urls(host, port, scheme) + self._print_access_urls(host, port, scheme, self.enable_webui) # 配置 Hypercorn config = HyperConfig() - binds: list[str] = [self._build_bind(host, port)] - if host == "::" and platform.system() in ("Windows", "Darwin"): - binds.append(self._build_bind("0.0.0.0", port)) config.bind = binds if ssl_enable: @@ -500,10 +494,17 @@ class AstrBotDashboard: except ValueError: return f"{host}:{port}" - def _print_access_urls(self, host: str, port: int, scheme: str = "http") -> None: + def _print_access_urls( + self, + host: str, + port: int, + scheme: str = "http", + enable_webui: bool = True, + ) -> None: local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses() + mode_label = "WebUI + API" if enable_webui else "API Server (WebUI 已分离)" - parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"] + parts = [f"\n ✨✨✨\n AstrBot v{VERSION} {mode_label} 已启动\n\n"] parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n") @@ -518,8 +519,15 @@ class AstrBotDashboard: display_url = f"{scheme}://{ip}:{port}" parts.append(f" ➜ 网络: {display_url}\n") + else: + if ":" in host: + parts.append(f" ➜ 指定监听: {scheme}://[{host}]:{port}\n") + else: + parts.append(f" ➜ 指定监听: {scheme}://{host}:{port}\n") - parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") + if enable_webui: + parts.append(" ➜ 默认用户名和密码: astrbot\n") + parts.append(" ✨✨✨\n") if not local_ips: parts.append(