diff --git a/.env.example b/.env.example index 8f27620d3..55700ab8e 100644 --- a/.env.example +++ b/.env.example @@ -1,93 +1,97 @@ # ========================================== -# AstrBot Environment Configuration Example +# AstrBot 环境配置示例 # ========================================== -# Copy this file to .env and adjust the values as needed. -# Note: Variables set here will override default configurations. - +# 将此文件复制为 .env 并根据需要修改。 +# 注意:在此处设置的变量将覆盖默认配置。 +# 程序会自动读取.env文件(通过cli启动时) # ------------------------------------------ -# Core Configuration (核心配置) +# 核心配置 # ------------------------------------------ -# AstrBot root directory path. Defaults to current working directory or ~/.astrbot for desktop client. +# AstrBot 根目录路径。默认:当前工作目录或桌面客户端为 ~/.astrbot(服务器为:/vat/lib/astrbot//) # ASTRBOT_ROOT=/path/to/astrbot -# Log level. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL. Default: INFO. +# 日志等级。可选值:DEBUG, INFO, WARNING, ERROR, CRITICAL。默认:INFO。 # ASTRBOT_LOG_LEVEL=INFO -# Enable plugin auto-reload. Set to "1" to enable. Useful for development. +# 启用插件热重载。设置为 "1" 启用(开发时有用)。 # ASTRBOT_RELOAD=0 -# Disable metrics upload. Set to "1" to disable anonymous usage statistics. -# ASTRBOT_DISABLE_METRICS=0 +# 禁用统计(上报匿名使用数据)。设置为 "1" 禁用。 +ASTRBOT_DISABLE_METRICS=0 -# Python executable path override (used for local code execution feature). +# 覆盖 Python 可执行文件路径(用于本地代码执行功能)。 # PYTHON=/usr/bin/python3 -# Enable demo mode (might restrict some features). +# 启用演示模式(可能限制部分功能)。 # DEMO_MODE=False -# Enable testing mode (affects logging and some behaviors). +# 启用测试模式(影响日志和部分行为)。 # TESTING=False -# Flag indicating execution via desktop client (Internal use mostly). +# 标记:是否通过桌面客户端执行(主要用于内部,不过如果需要测试,可以开启)。 # ASTRBOT_DESKTOP_CLIENT=0 -# Flag indicating execution via systemd service. +# 标记:是否通过 systemd 服务执行。 # ASTRBOT_SYSTEMD=0 # ------------------------------------------ -# Dashboard Configuration (管理面板配置) +# 管理面板配置 # ------------------------------------------ -# Enable or disable the WebUI Dashboard. Default: True. -# ASTRBOT_DASHBOARD_ENABLE=True - -# Dashboard bind host. Default: 0.0.0.0 (listen on all interfaces). -# ASTRBOT_DASHBOARD_HOST=0.0.0.0 - -# Dashboard bind port. Default: 6185. -# ASTRBOT_DASHBOARD_PORT=6185 - -# Enable SSL (HTTPS) for the dashboard. -# ASTRBOT_DASHBOARD_SSL_ENABLE=False - -# SSL Certificate path (required if SSL is enabled). -# ASTRBOT_DASHBOARD_SSL_CERT=/path/to/cert.pem - -# SSL Key path (required if SSL is enabled). -# ASTRBOT_DASHBOARD_SSL_KEY=/path/to/key.pem - -# SSL CA Certificates path (optional). -# ASTRBOT_DASHBOARD_SSL_CA_CERTS=/path/to/ca.pem +# 启用或禁用 WebUI 管理面板。默认:True。 +ASTRBOT_DASHBOARD_ENABLE=True # ------------------------------------------ -# Network Configuration (网络配置) +# 网络配置 # ------------------------------------------ -# HTTP/HTTPS Proxy URL (e.g., http://127.0.0.1:7890). +# HTTP/HTTPS 代理 URL(例如:http://127.0.0.1:7890)。 # http_proxy= # https_proxy= -# No proxy list (comma-separated domains/IPs to bypass proxy). +# 不走代理的主机列表(逗号分隔)。 # no_proxy=localhost,127.0.0.1 # ------------------------------------------ -# Integrations (第三方集成) +# 第三方集成 # ------------------------------------------ -# Alibaba DashScope API Key (used for Rerank service). +# Alibaba DashScope API Key(用于 Rerank 服务)。 # DASHSCOPE_API_KEY=sk-xxxxxxxxxxxx -# Coze Integration +# Coze 集成 # COZE_API_KEY= # COZE_BOT_ID= -# Computer Use data directory (for screenshot/file storage related to computer control). +# 计算机控制相关的数据目录(用于截图/文件存储)。 # BAY_DATA_DIR= # ------------------------------------------ -# Platform Specific (平台特定配置) +# 平台特定配置 # ------------------------------------------ -# Test mode for QQ Official Bot. +# QQ 官方机器人测试模式开关。 # TEST_MODE=off + +# ------------------------------------------ +# API 配置(若未单独设置则使用对应的 DASHBOARD 值) +# ------------------------------------------ + +# API 绑定主机(默认使用 ASTRBOT_DASHBOARD_HOST) +ASTRBOT_HOST=0.0.0.0 + +# API 绑定端口(默认使用 ASTRBOT_DASHBOARD_PORT) +ASTRBOT_PORT=6185 + +# 是否为 API 启用 SSL(true/false) +ASTRBOT_SSL_ENABLE=false + +# API SSL 证书路径(PEM 格式) +ASTRBOT_SSL_CERT="" + +# API SSL 私钥路径(PEM 格式) +ASTRBOT_SSL_KEY="" + +# API SSL CA 证书链路径(可选) +ASTRBOT_SSL_CA_CERTS="" diff --git a/astrbot/__main__.py b/astrbot/__main__.py index 5a65be95c..743a1a033 100644 --- a/astrbot/__main__.py +++ b/astrbot/__main__.py @@ -7,7 +7,6 @@ from pathlib import Path import anyio -import runtime_bootstrap from astrbot.core import LogBroker, LogManager, db_helper, logger from astrbot.core.config.default import VERSION from astrbot.core.initial_loader import InitialLoader @@ -25,8 +24,9 @@ from astrbot.core.utils.io import ( download_dashboard, get_dashboard_version, ) +from astrbot.runtime_bootstrap import initialize_runtime_bootstrap -runtime_bootstrap.initialize_runtime_bootstrap() +initialize_runtime_bootstrap() # 将父目录添加到 sys.path diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index f5ab15ed0..71b21a445 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -55,14 +55,14 @@ __all__ = [ "on_decorating_result", "on_llm_request", "on_llm_response", + "on_llm_tool_respond", + "on_platform_loaded", "on_plugin_error", "on_plugin_loaded", "on_plugin_unloaded", - "on_platform_loaded", + "on_using_llm_tool", "on_waiting_llm_request", "permission_type", "platform_adapter_type", "regex", - "on_using_llm_tool", - "on_llm_tool_respond", ] diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index de5e29f55..810863bbd 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -23,15 +23,22 @@ from typing import Any import click -try: - # Argon2 provides secure password hashing. Import if available. - from argon2 import PasswordHasher - from argon2 import exceptions as argon2_exceptions +# Provide type-safe placeholders for optional argon2 imports so static analysis / type checkers +# do not assume the symbols always exist at import time. +PasswordHasher: Any = None +argon2_exceptions: Any = None +_HAS_ARGON2 = False - _HAS_ARGON2 = True +try: + # Import argon2 at runtime if available. Use the module import path and getattr to avoid + # static-analysis import errors and to allow graceful fallback when argon2 isn't installed. + import argon2 as _argon2 # type: ignore + + PasswordHasher = getattr(_argon2, "PasswordHasher", None) + argon2_exceptions = getattr(_argon2, "exceptions", None) + _HAS_ARGON2 = PasswordHasher is not None and callable(PasswordHasher) except Exception: - # Argon2 may be broken on some Python/platform combinations (e.g. missing legacy `imp`). - # Fall back to a PBKDF2-HMAC-SHA256 based approach implemented below. + # Argon2 may be broken on some Python/platform combinations. PasswordHasher = None argon2_exceptions = None _HAS_ARGON2 = False @@ -42,7 +49,8 @@ from astrbot.core.utils.astrbot_path import astrbot_paths # Instantiate a module-level hasher (argon2 when available, else None). # When argon2 is unavailable we will use PBKDF2-HMAC-SHA256 as a deterministic secure fallback. -if _HAS_ARGON2: +_PASSWORD_HASHER: Any = None +if _HAS_ARGON2 and PasswordHasher is not None and callable(PasswordHasher): try: _PASSWORD_HASHER = PasswordHasher() except Exception: @@ -149,7 +157,17 @@ def verify_dashboard_password(value: str, stored_hash: str) -> bool: except Exception as e: # argon2 verification mismatch or errors: return False for mismatch, # raise for unexpected exceptions to avoid silent failures. - if getattr(e, "__class__", None).__name__ == "VerifyMismatchError": + # Be defensive when accessing exception type/name to avoid attribute errors. + # If argon2_exceptions module is available prefer isinstance check. + if argon2_exceptions is not None: + vm = getattr(argon2_exceptions, "VerifyMismatchError", None) + if vm is not None and isinstance(e, vm): + return False + cls = getattr(e, "__class__", None) + if ( + cls is not None + and getattr(cls, "__name__", "") == "VerifyMismatchError" + ): return False raise click.ClickException(f"Password verification failure (argon2): {e!s}") @@ -337,16 +355,26 @@ def set_dashboard_credentials( config, "dashboard.username", _validate_dashboard_username(username) ) if password_hash is not None: - # If caller provided plaintext by mistake, allow passing through validator, - # but prefer that callers pass a pre-hashed password when applicable. - if is_dashboard_password_hash(password_hash) and not password_hash.startswith( - "$argon2" - ): - # It's a legacy hex digest; store as-is for compatibility. - _set_nested_item(config, "dashboard.password", password_hash) - elif password_hash.startswith("$argon2"): + # Security policy: disallow storing legacy hex digests via CLI. + # Acceptable inputs from callers: + # - Argon2 encoded hash string (starts with "$argon2") + # - Plaintext password (will be hashed securely here) + # + # Rationale: legacy hex digests (md5/sha256 hex) are insecure to store and + # lead to ambiguity in verification. Require callers to provide plaintext + # (for secure hashing) or a properly encoded argon2 hash. + if isinstance(password_hash, str) and password_hash.startswith("$argon2"): _set_nested_item(config, "dashboard.password", password_hash) else: + # If caller mistakenly passed a legacy hex digest, reject with clear error. + if is_dashboard_password_hash(password_hash) and not str( + password_hash + ).startswith("$argon2"): + raise click.ClickException( + "Storing legacy hex password digests via CLI is disallowed. " + "Please provide the plaintext password (it will be hashed securely), " + "or provide an Argon2-encoded hash string." + ) # Treat value as plaintext and hash it securely _set_nested_item( config, @@ -447,14 +475,30 @@ def get_config(key: str | None = None) -> None: def set_dashboard_password(username: str | None, password: str | None) -> None: """ Interactively set dashboard password (with confirmation) or set directly with -p. + + Note: Legacy hex digests (md5/sha256 hex) provided directly are now disallowed + for CLI storage. Acceptable inputs: + - Plaintext password (recommended): it will be hashed securely before storage. + - Argon2-encoded hash (advanced): stored as-is. """ config = _load_config() if password is not None: - # If the provided value already looks like a supported hash, accept it. - if is_dashboard_password_hash(password): + # If the provided value is an Argon2 encoded hash, accept as-is. + if isinstance(password, str) and password.startswith("$argon2"): password_hash = password else: + # If the provided value looks like a legacy hex digest, reject and ask + # the user to provide plaintext so we can hash it securely. + if is_dashboard_password_hash(password) and not str(password).startswith( + "$argon2" + ): + raise click.ClickException( + "Providing legacy hex password digests is disallowed. " + "Please supply the plaintext password (it will be hashed securely), " + "or provide an Argon2-encoded hash string." + ) + # Otherwise treat as plaintext and hash securely password_hash = _validate_dashboard_password(password) else: password_hash = prompt_dashboard_password() diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index 7217bd4ca..3bd8cc23d 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -13,14 +13,19 @@ Core: - `DEMO_MODE`: Enable demo mode. - `PYTHON`: Python executable path override (for local code execution). -Dashboard: +Dashboard / Backend: - `ASTRBOT_DASHBOARD_ENABLE` / `DASHBOARD_ENABLE`: Enable/Disable Dashboard. -- `ASTRBOT_DASHBOARD_HOST` / `DASHBOARD_HOST`: Dashboard bind host. -- `ASTRBOT_DASHBOARD_PORT` / `DASHBOARD_PORT`: Dashboard bind port. -- `ASTRBOT_DASHBOARD_SSL_ENABLE` / `DASHBOARD_SSL_ENABLE`: Enable SSL. -- `ASTRBOT_DASHBOARD_SSL_CERT` / `DASHBOARD_SSL_CERT`: SSL Certificate path. -- `ASTRBOT_DASHBOARD_SSL_KEY` / `DASHBOARD_SSL_KEY`: SSL Key path. -- `ASTRBOT_DASHBOARD_SSL_CA_CERTS` / `DASHBOARD_SSL_CA_CERTS`: SSL CA Certs path. +- `ASTRBOT_HOST` / `DASHBOARD_HOST`: Dashboard bind host. +- `ASTRBOT_PORT` / `DASHBOARD_PORT`: Dashboard bind port. + +Backend-standard SSL names (preferred for server): +- `ASTRBOT_SSL_ENABLE` / `ASTRBOT_DASHBOARD_SSL_ENABLE`: Enable SSL for API. +- `ASTRBOT_SSL_CERT` / `ASTRBOT_DASHBOARD_SSL_CERT`: SSL Certificate path for backend. +- `ASTRBOT_SSL_KEY` / `ASTRBOT_DASHBOARD_SSL_KEY`: SSL Key path for backend. +- `ASTRBOT_SSL_CA_CERTS` / `ASTRBOT_DASHBOARD_SSL_CA_CERTS`: SSL CA Certs path for backend. + +Legacy compatibility: +- The CLI will set both `ASTRBOT_SSL_*` and the legacy `ASTRBOT_DASHBOARD_SSL_*` / `DASHBOARD_SSL_*` names to remain compatible. Network: - `http_proxy` / `https_proxy`: Proxy URL. @@ -35,18 +40,191 @@ Platform Specific: - `TEST_MODE`: Test mode for QQOfficial. """ +from __future__ import annotations + import asyncio import os +import re import sys import traceback +from collections.abc import Iterable from pathlib import Path import click +from dotenv import load_dotenv from filelock import FileLock, Timeout +from astrbot.cli.utils import check_astrbot_root, check_dashboard from astrbot.core.utils.astrbot_path import astrbot_paths +from astrbot.runtime_bootstrap import initialize_runtime_bootstrap -from ..utils import check_astrbot_root, check_dashboard +initialize_runtime_bootstrap() +# 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 + + +def expand_value( + value: str, + env: dict[str, str] | None = None, + local: dict[str, str] | None = None, +) -> str: + """Expand bash-like ${VAR:-default} and ${VAR} placeholders in `value`. + + This resolves references from `local` first (previously parsed keys), then from `env`. + Repeats expansion until stable or a maximum iteration count is reached to allow + nested expansions. + """ + if env is None: + env = dict(os.environ) + if local is None: + local = {} + + # Fast path: if there's no ${...} pattern, return as-is + if "${" not in value: + return value + + def _repl(m: re.Match) -> str: + return _expand_parameter(m, env=env, local=local) + + prev = None + current = value + # Allow nested expansions but prevent infinite loops + for _ in range(8): + new = _PARAM_EXPAND_RE.sub(_repl, current) + if new == current: + break + prev, current = current, new + return current + + +def parse_service_config_file( + path: Path, env: dict[str, str] | None = None +) -> dict[str, str]: + """Parse a service/config template file supporting: + - KEY=VALUE lines (with optional surrounding quotes) + - export KEY=VALUE + - Comments starting with '#' + - Bash-like parameter expansions ${VAR:-default} and ${VAR} + Returns a dictionary of parsed keys -> expanded values (expansion can reference env and earlier keys). + """ + if env is None: + env = dict(os.environ) + + parsed: dict[str, str] = {} + + text = path.read_text(encoding="utf-8") + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + # Allow "export KEY=VALUE" too + if line.startswith("export "): + line = line[len("export ") :].strip() + + if "=" not in line: + # Not a key-value, skip + continue + + key, val = line.split("=", 1) + key = key.strip() + val = val.strip() + + # Remove surrounding quotes if present + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + + # Expand placeholders using current parsed + environment + expanded = expand_value(val, env=env, local=parsed) + parsed[key] = expanded + + return parsed + + +def find_autodetected_template( + candidates: Iterable[Path] | None = None, +) -> Path | None: + """Try to locate a likely 'config.template' automatically. + + Heuristics used (in order): + - If explicit environment variables point to a template, use them. + - Provided candidates (if any). + - Look up from current file location for a sibling 'config.template' within ancestor directories. + - Common system / user locations. + """ + # Env overrides + for envvar in ( + "AUR_CONFIG_PATH", + "ASTRBOT_CONFIG_TEMPLATE", + "SERVICE_CONFIG", + "CONFIG_TEMPLATE", + ): + val = os.environ.get(envvar) + if val: + p = Path(val) + if p.exists(): + return p + + # Provided explicit candidates + if candidates: + for c in candidates: + try: + p = Path(c) + except TypeError: + continue + if p.exists(): + return p + + # Search upward from this file for a 'config.template' (useful for AUR checkout) + here = Path(__file__).resolve() + for parent in [here] + list(here.parents)[:6]: + p = parent / "config.template" + if p.exists(): + return p + # also check a sibling directory named 'astrbot-git' or 'AstrBot' that might contain the file + for sibling in ("astrbot-git", "AstrBot", "astrbot"): + candidate = parent / sibling / "config.template" + if candidate.exists(): + return candidate + + # Common locations + common = [ + Path.cwd() / "config.template", + Path.cwd() / ".env", + Path("/etc/astrbot/config.template"), + Path("/var/lib/astrbot/config.template"), + Path.home() / ".config" / "astrbot" / "config.template", + Path.home() / "astrbot-git" / "config.template", + ] + for p in common: + if p.exists(): + return p + + return None async def run_astrbot(astrbot_root: Path) -> None: @@ -58,7 +236,7 @@ async def run_astrbot(astrbot_root: Path) -> None: os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE")) == "True" ): - # 避免在 systemd 模式下因等待输入而阻塞 + # Avoid blocking when running under systemd by waiting for input if os.environ.get("ASTRBOT_SYSTEMD") != "1": await check_dashboard(astrbot_root) @@ -78,7 +256,7 @@ async def run_astrbot(astrbot_root: Path) -> None: @click.option( "--service-config", "-c", - help="Service configuration file path", + help="Service configuration file path (supports ${VAR:-default} style expansion)", required=False, type=str, ) @@ -97,6 +275,24 @@ async def run_astrbot(astrbot_root: Path) -> None: 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( @@ -107,6 +303,9 @@ def run( service_config: str, backend_only: bool, log_level: str, + ssl_cert: str, + ssl_key: str, + ssl_ca: str, debug: bool, ) -> None: """Run AstrBot""" @@ -114,50 +313,120 @@ def run( if debug: log_level = "DEBUG" + # --- Step 1: If a service config is provided, read and parse it to local overrides --- + parsed_service: dict[str, str] = {} + # If explicit config path provided, use it. Otherwise attempt autodetection. + svc_path: Path | None = None if service_config: - svc_path = Path(service_config) - if svc_path.exists(): - content = svc_path.read_text(encoding="utf-8") - for line in content.splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, value = line.split("=", 1) - key = key.strip() - value = value.strip() - # Remove quotes - if (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ): - value = value[1:-1] + candidate = Path(service_config) + if candidate.exists(): + svc_path = candidate + else: + # Try to expand user and resolve + candidate = Path(os.path.expanduser(service_config)) + if candidate.exists(): + svc_path = candidate - if key == "HOST" and not host: - host = value - elif key == "PORT" and not port: - port = value - elif key == "ASTRBOT_ROOT" and not root: - root = value + if svc_path is not None: + parsed_service = parse_service_config_file(svc_path) + else: + # Auto-detect possible config.template (AUR or other) + autodetected = find_autodetected_template() + if autodetected: + # prefer 'config.template' but don't force; parse if found + parsed_service = parse_service_config_file(autodetected) + svc_path = autodetected + + # Local variables (CLI args) should keep the highest precedence. + # Apply parsed service values only if CLI didn't supply them. + # Recognized keys we care about: HOST, PORT, ASTRBOT_ROOT, ASTRBOT_PORT, ASTRBOT_HOST + if parsed_service: + if not host: + host = ( + parsed_service.get("HOST") + or parsed_service.get("ASTRBOT_HOST") + or parsed_service.get("DASHBOARD_HOST") + or host + ) + if not port: + port = ( + parsed_service.get("PORT") + or parsed_service.get("ASTRBOT_PORT") + or parsed_service.get("DASHBOARD_PORT") + or port + ) + if not root: + root = ( + parsed_service.get("ASTRBOT_ROOT") + or parsed_service.get("ASTRBOT_HOME") + or parsed_service.get("ROOT") + or root + ) + + # Also export other useful keys from parsed_service into environment, + # but do not override existing environment variables. + for k, v in parsed_service.items(): + if k not in os.environ: + os.environ[k] = v + + # --- Step 2: Load .env files early so file-based environment vars are available. --- + # Precedence principle implemented here: + # 1. CLI args (local variables) keep highest priority and will not be overwritten. + # 2. Service config (already applied to local variables above) has next priority. + # 3. Environment variables and .env files provide defaults and are loaded here. + # Loading .env files should NOT override existing environment variables. + dotenv_candidates = [] + + # Prefer .env in current working directory first + dotenv_candidates.append(Path.cwd() / ".env") + + # If ASTRBOT_ROOT already set in environment, try loading .env from there as well + astrbot_root_env = os.environ.get("ASTRBOT_ROOT") + if astrbot_root_env: + dotenv_candidates.append(Path(astrbot_root_env) / ".env") + + # Also try loading from the packaged default astrbot_paths.root location + try: + dotenv_candidates.append(astrbot_paths.root / ".env") + except Exception: + # astrbot_paths.root should normally be available, but be defensive + pass + + for p in dotenv_candidates: + if p.exists(): + # load_dotenv with override=False will NOT overwrite existing os.environ values + load_dotenv(dotenv_path=str(p), override=False) # Normalize environment variables for backward compatibility - # If the legacy env var is set but the new one isn't, copy it over. + # If legacy env vars are set but the preferred new ones aren't, copy them over. env_map = { + # Dashboard legacy -> standardized dashboard-prefixed "DASHBOARD_ENABLE": "ASTRBOT_DASHBOARD_ENABLE", - "DASHBOARD_HOST": "ASTRBOT_DASHBOARD_HOST", - "DASHBOARD_PORT": "ASTRBOT_DASHBOARD_PORT", - "DASHBOARD_SSL_ENABLE": "ASTRBOT_DASHBOARD_SSL_ENABLE", - "DASHBOARD_SSL_CERT": "ASTRBOT_DASHBOARD_SSL_CERT", - "DASHBOARD_SSL_KEY": "ASTRBOT_DASHBOARD_SSL_KEY", - "DASHBOARD_SSL_CA_CERTS": "ASTRBOT_DASHBOARD_SSL_CA_CERTS", + "DASHBOARD_HOST": "ASTRBOT_HOST", + "DASHBOARD_PORT": "ASTRBOT_PORT", + "DASHBOARD_SSL_ENABLE": "ASTRBOT_SSL_ENABLE", + "DASHBOARD_SSL_CERT": "ASTRBOT_SSL_CERT", + "DASHBOARD_SSL_KEY": "ASTRBOT_SSL_KEY", + "DASHBOARD_SSL_CA_CERTS": "ASTRBOT_SSL_CA_CERTS", + # Some packages used alternate names + "ASTRBOT_DASHBOARD_SSL_CERT": "ASTRBOT_SSL_CERT", } for legacy, new in env_map.items(): if legacy in os.environ and new not in os.environ: os.environ[new] = os.environ[legacy] + # Mark CLI execution os.environ["ASTRBOT_CLI"] = "1" + + # 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 @@ -166,17 +435,37 @@ def run( 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_DASHBOARD_PORT"] = port - os.environ["DASHBOARD_PORT"] = port # 今后应该移除 + os.environ["ASTRBOT_PORT"] = port + os.environ["DASHBOARD_PORT"] = port # legacy + # If CLI didn't provide port but env/.env provided ASTRBOT_DASHBOARD_PORT, leave it as-is. + if host is not None: - os.environ["ASTRBOT_DASHBOARD_HOST"] = host - os.environ["DASHBOARD_HOST"] = host # 今后应该移除 + os.environ["ASTRBOT_HOST"] = host + os.environ["DASHBOARD_HOST"] = host # legacy + # If CLI didn't provide host but env/.env provided ASTRBOT_DASHBOARD_HOST, leave it as-is. + + # CLI-provided SSL paths should set backend-standard env names (preferred), + # and also set legacy/dashboard names for compatibility. + if ssl_cert is not None: + os.environ["ASTRBOT_SSL_CERT"] = ssl_cert + os.environ["DASHBOARD_SSL_CERT"] = ssl_cert + if ssl_key is not None: + os.environ["ASTRBOT_SSL_KEY"] = ssl_key + os.environ["DASHBOARD_SSL_KEY"] = ssl_key + if ssl_ca is not None: + os.environ["ASTRBOT_SSL_CA_CERTS"] = ssl_ca + os.environ["DASHBOARD_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["DASHBOARD_ENABLE"] = str(not backend_only) # 今后应该移除 + os.environ["DASHBOARD_ENABLE"] = str(not backend_only) # legacy + os.environ["ASTRBOT_LOG_LEVEL"] = log_level if reload: @@ -197,18 +486,31 @@ def run( "PYTHON", "ASTRBOT_DASHBOARD_ENABLE", "DASHBOARD_ENABLE", - "ASTRBOT_DASHBOARD_HOST", + "ASTRBOT_HOST", "DASHBOARD_HOST", - "ASTRBOT_DASHBOARD_PORT", + "ASTRBOT_PORT", "DASHBOARD_PORT", - "ASTRBOT_DASHBOARD_SSL_ENABLE", + # Dashboard SSL (legacy) + "ASTRBOT_SSL_ENABLE", "DASHBOARD_SSL_ENABLE", - "ASTRBOT_DASHBOARD_SSL_CERT", + "ASTRBOT_SSL_CERT", "DASHBOARD_SSL_CERT", - "ASTRBOT_DASHBOARD_SSL_KEY", + "ASTRBOT_SSL_KEY", "DASHBOARD_SSL_KEY", - "ASTRBOT_DASHBOARD_SSL_CA_CERTS", + "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", + # API specific envs + "ASTRBOT_API_HOST", + "ASTRBOT_API_PORT", + "ASTRBOT_API_SSL_ENABLE", + "ASTRBOT_API_SSL_CERT", + "ASTRBOT_API_SSL_KEY", + "ASTRBOT_API_SSL_CA_CERTS", "http_proxy", "https_proxy", "no_proxy", @@ -228,6 +530,10 @@ def run( 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" @@ -241,4 +547,5 @@ def run( "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()}") diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index 68d978c9a..a4f9d8081 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -66,16 +66,16 @@ pip_installer = PipInstaller( astrbot_config.get("pypi_index_url", None), ) __all__ = [ - "AstrBotConfig", "DEMO_MODE", - "astrbot_config", - "t2i_base_url", - "html_renderer", - "logger", + "AstrBotConfig", "LogBroker", "LogManager", + "astrbot_config", "db_helper", - "sp", "file_token_service", + "html_renderer", + "logger", "pip_installer", + "sp", + "t2i_base_url", ] diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index d673a7b2d..1646a21a9 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -150,7 +150,7 @@ class MCPClient: # Handle MCP service error logs if isinstance(msg, mcp.types.LoggingMessageNotificationParams): if msg.level in ("warning", "error", "critical", "alert", "emergency"): - log_msg = f"[{msg.level.upper()}] {str(msg.data)}" + log_msg = f"[{msg.level.upper()}] {msg.data!s}" self.server_errlogs.append(log_msg) if "url" in cfg: @@ -228,7 +228,7 @@ class MCPClient: "alert", "emergency", ): - log_msg = f"[{msg.level.upper()}] {str(msg.data)}" + log_msg = f"[{msg.level.upper()}] {msg.data!s}" self.server_errlogs.append(log_msg) stdio_transport = await self.exit_stack.enter_async_context( diff --git a/astrbot/core/agent/runners/coze/coze_agent_runner.py b/astrbot/core/agent/runners/coze/coze_agent_runner.py index a8300bb71..08acd6614 100644 --- a/astrbot/core/agent/runners/coze/coze_agent_runner.py +++ b/astrbot/core/agent/runners/coze/coze_agent_runner.py @@ -91,15 +91,15 @@ class CozeAgentRunner(BaseAgentRunner[TContext]): async for response in self._execute_coze_request(): yield response except Exception as e: - logger.error(f"Coze 请求失败:{str(e)}") + logger.error(f"Coze 请求失败:{e!s}") self._transition_state(AgentState.ERROR) self.final_llm_resp = LLMResponse( - role="err", completion_text=f"Coze 请求失败:{str(e)}" + role="err", completion_text=f"Coze 请求失败:{e!s}" ) yield AgentResponse( type="err", data=AgentResponseData( - chain=MessageChain().message(f"Coze 请求失败:{str(e)}") + chain=MessageChain().message(f"Coze 请求失败:{e!s}") ), ) finally: diff --git a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py index 8169a678c..cd016e526 100644 --- a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +++ b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py @@ -103,15 +103,15 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]): async for response in self._execute_dashscope_request(): yield response except Exception as e: - logger.error(f"阿里云百炼请求失败:{str(e)}") + logger.error(f"阿里云百炼请求失败:{e!s}") self._transition_state(AgentState.ERROR) self.final_llm_resp = LLMResponse( - role="err", completion_text=f"阿里云百炼请求失败:{str(e)}" + role="err", completion_text=f"阿里云百炼请求失败:{e!s}" ) yield AgentResponse( type="err", data=AgentResponseData( - chain=MessageChain().message(f"阿里云百炼请求失败:{str(e)}") + chain=MessageChain().message(f"阿里云百炼请求失败:{e!s}") ), ) diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index 5c40fecac..5a6659ae8 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -84,15 +84,15 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): async for response in self._execute_dify_request(): yield response except Exception as e: - logger.error(f"Dify 请求失败:{str(e)}") + logger.error(f"Dify 请求失败:{e!s}") self._transition_state(AgentState.ERROR) self.final_llm_resp = LLMResponse( - role="err", completion_text=f"Dify 请求失败:{str(e)}" + role="err", completion_text=f"Dify 请求失败:{e!s}" ) yield AgentResponse( type="err", data=AgentResponseData( - chain=MessageChain().message(f"Dify 请求失败:{str(e)}") + chain=MessageChain().message(f"Dify 请求失败:{e!s}") ), ) finally: diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index ed83a603c..36d409a37 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -269,7 +269,7 @@ async def run_agent( err_msg = ( f"Error occurred during AI execution.\n" f"Error Type: {type(e).__name__}\n" - f"Error Message: {str(e)}" + f"Error Message: {e!s}" ) error_llm_response = LLMResponse( diff --git a/astrbot/core/backup/__init__.py b/astrbot/core/backup/__init__.py index 8e33ef970..ba96d9edf 100644 --- a/astrbot/core/backup/__init__.py +++ b/astrbot/core/backup/__init__.py @@ -16,11 +16,11 @@ from .exporter import AstrBotExporter from .importer import AstrBotImporter, ImportPreCheckResult __all__ = [ + "BACKUP_MANIFEST_VERSION", + "KB_METADATA_MODELS", + "MAIN_DB_MODELS", "AstrBotExporter", "AstrBotImporter", "ImportPreCheckResult", - "MAIN_DB_MODELS", - "KB_METADATA_MODELS", "get_backup_directories", - "BACKUP_MANIFEST_VERSION", ] diff --git a/astrbot/core/computer/booters/boxlite.py b/astrbot/core/computer/booters/boxlite.py index f1e17d261..3ea736346 100644 --- a/astrbot/core/computer/booters/boxlite.py +++ b/astrbot/core/computer/booters/boxlite.py @@ -97,7 +97,7 @@ class MockShipyardSandboxClient: logger.error("[Computer] file_upload_failed booter=boxlite error=%s", e) return { "success": False, - "error": f"Connection error: {str(e)}", + "error": f"Connection error: {e!s}", "message": "File upload failed", } except asyncio.TimeoutError: @@ -126,7 +126,7 @@ class MockShipyardSandboxClient: ) return { "success": False, - "error": f"Internal error: {str(exc)}", + "error": f"Internal error: {exc!s}", "message": "File upload failed", } diff --git a/astrbot/core/computer/olayer/__init__.py b/astrbot/core/computer/olayer/__init__.py index e2348671e..261f9de9c 100644 --- a/astrbot/core/computer/olayer/__init__.py +++ b/astrbot/core/computer/olayer/__init__.py @@ -4,8 +4,8 @@ from .python import PythonComponent from .shell import ShellComponent __all__ = [ + "BrowserComponent", + "FileSystemComponent", "PythonComponent", "ShellComponent", - "FileSystemComponent", - "BrowserComponent", ] diff --git a/astrbot/core/computer/tools/__init__.py b/astrbot/core/computer/tools/__init__.py index 598abbb6e..9563f146e 100644 --- a/astrbot/core/computer/tools/__init__.py +++ b/astrbot/core/computer/tools/__init__.py @@ -17,23 +17,23 @@ from .python import LocalPythonTool, PythonTool from .shell import ExecuteShellTool __all__ = [ - "BrowserExecTool", - "BrowserBatchExecTool", - "RunBrowserSkillTool", - "GetExecutionHistoryTool", "AnnotateExecutionTool", - "CreateSkillPayloadTool", - "GetSkillPayloadTool", + "BrowserBatchExecTool", + "BrowserExecTool", "CreateSkillCandidateTool", - "ListSkillCandidatesTool", + "CreateSkillPayloadTool", "EvaluateSkillCandidateTool", - "PromoteSkillCandidateTool", - "ListSkillReleasesTool", - "RollbackSkillReleaseTool", - "SyncSkillReleaseTool", - "FileUploadTool", - "PythonTool", - "LocalPythonTool", "ExecuteShellTool", "FileDownloadTool", + "FileUploadTool", + "GetExecutionHistoryTool", + "GetSkillPayloadTool", + "ListSkillCandidatesTool", + "ListSkillReleasesTool", + "LocalPythonTool", + "PromoteSkillCandidateTool", + "PythonTool", + "RollbackSkillReleaseTool", + "RunBrowserSkillTool", + "SyncSkillReleaseTool", ] diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py index 70061ac31..45591b068 100644 --- a/astrbot/core/computer/tools/browser.py +++ b/astrbot/core/computer/tools/browser.py @@ -91,7 +91,7 @@ class BrowserExecTool(FunctionTool): ) return _to_json(result) except Exception as e: - return f"Error executing browser command: {str(e)}" + return f"Error executing browser command: {e!s}" @dataclass @@ -155,7 +155,7 @@ class BrowserBatchExecTool(FunctionTool): ) return _to_json(result) except Exception as e: - return f"Error executing browser batch command: {str(e)}" + return f"Error executing browser batch command: {e!s}" @dataclass @@ -201,4 +201,4 @@ class RunBrowserSkillTool(FunctionTool): ) return _to_json(result) except Exception as e: - return f"Error running browser skill: {str(e)}" + return f"Error running browser skill: {e!s}" diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index b5082b470..4e92c31ac 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -142,7 +142,7 @@ class FileUploadTool(FunctionTool): return f"File uploaded successfully to {file_path}" except Exception as e: logger.error(f"Error uploading file {local_path}: {e}") - return f"Error uploading file: {str(e)}" + return f"Error uploading file: {e!s}" @dataclass @@ -213,4 +213,4 @@ class FileDownloadTool(FunctionTool): return f"File downloaded successfully to {local_path}" except Exception as e: logger.error(f"Error downloading file {remote_path}: {e}") - return f"Error downloading file: {str(e)}" + return f"Error downloading file: {e!s}" diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py index e60648144..b5f960e4e 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/computer/tools/neo_skills.py @@ -66,7 +66,7 @@ class NeoSkillToolBase(FunctionTool): result = await neo_call(client, sandbox) return _to_json_text(result) except Exception as e: - return f"{self.error_prefix} {error_action}: {str(e)}" + return f"{self.error_prefix} {error_action}: {e!s}" @dataclass @@ -422,7 +422,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase): } ) except Exception as e: - return f"Error promoting skill candidate: {str(e)}" + return f"Error promoting skill candidate: {e!s}" @dataclass diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/computer/tools/python.py index bf9aaa14e..e1e79035f 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/computer/tools/python.py @@ -80,7 +80,7 @@ class PythonTool(FunctionTool): result = await sb.python.exec(code, silent=silent) return await handle_result(result, context.context.event) except Exception as e: - return f"Error executing code: {str(e)}" + return f"Error executing code: {e!s}" @dataclass @@ -103,4 +103,4 @@ class LocalPythonTool(FunctionTool): result = await sb.python.exec(code, silent=silent) return await handle_result(result, context.context.event) except Exception as e: - return f"Error executing code: {str(e)}" + return f"Error executing code: {e!s}" diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index d9fb25e7d..22f61e3ee 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -72,4 +72,4 @@ class ExecuteShellTool(FunctionTool): ) return json.dumps(result) except Exception as e: - return f"Error executing command: {str(e)}" + return f"Error executing command: {e!s}" diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index 43162696a..c6b86d1cd 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -95,7 +95,7 @@ Text chunk to process: return [] except Exception as e: logger.warning( - f" - LLM call failed on attempt {attempt + 1}/{max_retries + 1}. Error: {str(e)}" + f" - LLM call failed on attempt {attempt + 1}/{max_retries + 1}. Error: {e!s}" ) logger.error( @@ -635,7 +635,7 @@ class KBHelper: final_chunks = [] for i, result in enumerate(repaired_results): if isinstance(result, Exception): - logger.warning(f"块 {i} 处理异常: {str(result)}. 回退到原始块。") + logger.warning(f"块 {i} 处理异常: {result!s}. 回退到原始块。") final_chunks.append(initial_chunks[i]) elif isinstance(result, list): final_chunks.extend(result) diff --git a/astrbot/core/pipeline/__init__.py b/astrbot/core/pipeline/__init__.py index 6a6069ff7..4d851c2f7 100644 --- a/astrbot/core/pipeline/__init__.py +++ b/astrbot/core/pipeline/__init__.py @@ -80,6 +80,7 @@ if TYPE_CHECKING: from .whitelist_check.stage import WhitelistCheckStage __all__ = [ + "STAGES_ORDER", "ContentSafetyCheckStage", "EventResultType", "MessageEventResult", @@ -89,7 +90,6 @@ __all__ = [ "RespondStage", "ResultDecorateStage", "SessionStatusCheckStage", - "STAGES_ORDER", "WakingCheckStage", "WhitelistCheckStage", ] diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py index ffaec00b4..7ad1de056 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py @@ -86,7 +86,7 @@ async def run_third_party_agent( err_msg = ( f"Error occurred during AI execution.\n" f"Error Type: {type(e).__name__} (3rd party)\n" - f"Error Message: {str(e)}" + f"Error Message: {e!s}" ) yield MessageChain().message(err_msg), True diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index fe0ee3922..fc341c9d2 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -79,7 +79,7 @@ class WebChatMessageEvent(AstrMessageEvent): ) elif isinstance(comp, Image): # save image to local - filename = f"{str(uuid.uuid4())}.jpg" + filename = f"{uuid.uuid4()!s}.jpg" path = os.path.join(attachments_dir, filename) image_base64 = await comp.convert_to_base64() async with aiofiles.open(path, "wb") as f: @@ -95,7 +95,7 @@ class WebChatMessageEvent(AstrMessageEvent): ) elif isinstance(comp, Record): # save record to local - filename = f"{str(uuid.uuid4())}.wav" + filename = f"{uuid.uuid4()!s}.wav" path = os.path.join(attachments_dir, filename) record_base64 = await comp.convert_to_base64() async with aiofiles.open(path, "wb") as f: diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 4ab2b051e..bbd834748 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -84,7 +84,7 @@ class _MCPClientDictView(Mapping[str, MCPClient]): def _resolve_timeout( - timeout: float | int | str | None = None, + timeout: float | str | None = None, *, env_name: str = MCP_INIT_TIMEOUT_ENV, default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS, @@ -670,7 +670,7 @@ class FunctionToolManager: name: str, config: dict, shutdown_event: asyncio.Event | None = None, - init_timeout: float | int | str | None = None, + init_timeout: float | str | None = None, ) -> None: """Enable a new MCP server and initialize it. diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index efee77376..e7fbc989a 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -370,7 +370,7 @@ class ProviderGoogleGenAI(Provider): # we should set thought_signature back to part if exists # for more info about thought_signature, see: # https://ai.google.dev/gemini-api/docs/thought-signatures - if "extra_content" in tool and tool["extra_content"]: + if tool.get("extra_content"): ts_bs64 = ( tool["extra_content"] .get("google", {}) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index b02ffc5e9..880e708f6 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -160,7 +160,7 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str: if skill.source_type == "sandbox_only": rendered_path = ( - f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/" + f"{SANDBOX_WORKSPACE_ROOT!s}/{SANDBOX_SKILLS_ROOT!s}/" f"{display_name}/SKILL.md" ) else: diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index cd5050979..8b0d616c8 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -467,7 +467,7 @@ class Context: await platform.send_by_session(session, message_chain) return True logger.warning( - f"cannot find platform for session {str(session)}, message not sent" + f"cannot find platform for session {session!s}, message not sent" ) return False diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 5e99948cd..88b576de9 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -35,15 +35,15 @@ __all__ = [ "register_on_decorating_result", "register_on_llm_request", "register_on_llm_response", + "register_on_llm_tool_respond", + "register_on_platform_loaded", "register_on_plugin_error", "register_on_plugin_loaded", "register_on_plugin_unloaded", - "register_on_platform_loaded", + "register_on_using_llm_tool", "register_on_waiting_llm_request", "register_permission_type", "register_platform_adapter_type", "register_regex", "register_star", - "register_on_using_llm_tool", - "register_on_llm_tool_respond", ] diff --git a/astrbot/core/utils/core_constraints.py b/astrbot/core/utils/core_constraints.py index 3a8b0e0d5..e9ad346f0 100644 --- a/astrbot/core/utils/core_constraints.py +++ b/astrbot/core/utils/core_constraints.py @@ -1,9 +1,10 @@ +import asyncio import contextlib import functools import importlib.metadata as importlib_metadata import logging import os -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator from packaging.requirements import Requirement @@ -99,6 +100,8 @@ class CoreConstraintsProvider: @contextlib.contextmanager def constraints_file(self) -> Iterator[str | None]: + """Synchronous context manager kept for backward compatibility with tests and + synchronous callers. Creates a temporary constraints file and yields its path.""" constraints = _get_core_constraints(self._core_dist_name) if not constraints: yield None @@ -125,3 +128,46 @@ class CoreConstraintsProvider: if path and os.path.exists(path): with contextlib.suppress(Exception): os.remove(path) + + @contextlib.asynccontextmanager + async def async_constraints_file(self) -> AsyncIterator[str | None]: + """Asynchronous variant of constraints_file for use with `async with`. + + This is provided so async callers can obtain a temporary constraints file + without blocking the event loop. Internally it offloads blocking file + creation/removal to a thread via asyncio.to_thread. + """ + constraints = _get_core_constraints(self._core_dist_name) + if not constraints: + yield None + return + + path: str | None = None + try: + import tempfile + + def _make_tmp() -> str: + with tempfile.NamedTemporaryFile( + mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8" + ) as f: + f.write("\n".join(constraints)) + return f.name + + path = await asyncio.to_thread(_make_tmp) + logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints)) + except Exception as exc: + logger.warning("创建临时约束文件失败: %s", exc) + yield None + return + + try: + yield path + finally: + if path: + try: + exists = await asyncio.to_thread(os.path.exists, path) + if exists: + await asyncio.to_thread(os.remove, path) + except Exception: + # Ensure we never raise while cleaning up + pass diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 8aad8db75..2ef33331f 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -1005,7 +1005,9 @@ class PipInstaller: ] ) - with self._core_constraints.constraints_file() as constraints_file_path: + async with ( + self._core_constraints.async_constraints_file() as constraints_file_path + ): if constraints_file_path: args.extend(["-c", constraints_file_path]) diff --git a/astrbot/core/utils/quoted_message/__init__.py b/astrbot/core/utils/quoted_message/__init__.py index 8421898fd..a9e24391c 100644 --- a/astrbot/core/utils/quoted_message/__init__.py +++ b/astrbot/core/utils/quoted_message/__init__.py @@ -3,6 +3,6 @@ from __future__ import annotations from .extractor import extract_quoted_message_images, extract_quoted_message_text __all__ = [ - "extract_quoted_message_text", "extract_quoted_message_images", + "extract_quoted_message_text", ] diff --git a/astrbot/core/utils/quoted_message_parser.py b/astrbot/core/utils/quoted_message_parser.py index fa6ac18dd..c14e7f884 100644 --- a/astrbot/core/utils/quoted_message_parser.py +++ b/astrbot/core/utils/quoted_message_parser.py @@ -6,6 +6,6 @@ from astrbot.core.utils.quoted_message.extractor import ( ) __all__ = [ - "extract_quoted_message_text", "extract_quoted_message_images", + "extract_quoted_message_text", ] diff --git a/astrbot/core/utils/temp_dir_cleaner.py b/astrbot/core/utils/temp_dir_cleaner.py index c0c060098..ec7e2bc61 100644 --- a/astrbot/core/utils/temp_dir_cleaner.py +++ b/astrbot/core/utils/temp_dir_cleaner.py @@ -7,7 +7,7 @@ from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -def parse_size_to_bytes(value: str | int | float | None) -> int: +def parse_size_to_bytes(value: str | float | None) -> int: """Parse size in MB to bytes.""" if value is None: return 0 diff --git a/astrbot/core/utils/webhook_utils.py b/astrbot/core/utils/webhook_utils.py index c4dfc9c6f..b0c27888f 100644 --- a/astrbot/core/utils/webhook_utils.py +++ b/astrbot/core/utils/webhook_utils.py @@ -22,7 +22,7 @@ def _get_dashboard_port() -> int: def _is_dashboard_ssl_enabled() -> bool: - env_ssl = os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE") or os.environ.get( + env_ssl = os.environ.get("ASTRBOT_SSL_ENABLE") or os.environ.get( "DASHBOARD_SSL_ENABLE" ) if env_ssl is not None: diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index 7e6e79146..4af4e4178 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -37,20 +37,20 @@ __all__ = [ "CronRoute", "FileRoute", "KnowledgeBaseRoute", + "LiveChatRoute", "LogRoute", "OpenApiRoute", "PersonaRoute", "PlatformRoute", "PluginRoute", + "Response", + "RouteContext", "SessionManagementRoute", + "SkillsRoute", "StatRoute", "StaticFileRoute", "SubAgentRoute", - "ToolsRoute", - "SkillsRoute", - "UpdateRoute", "T2iRoute", - "LiveChatRoute", - "Response", - "RouteContext", + "ToolsRoute", + "UpdateRoute", ] diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index e2a7458b4..f4fa3a429 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -8,6 +8,8 @@ from astrbot import logger from astrbot.cli.commands.cmd_conf import ( DEFAULT_DASHBOARD_PASSWORD_MD5, DEFAULT_DASHBOARD_PASSWORD_SHA256, + hash_dashboard_password_secure, + verify_dashboard_password, ) from astrbot.core import DEMO_MODE @@ -41,6 +43,33 @@ class AuthRoute(Route): change_pwd_hint = True logger.warning("为了保证安全,请尽快修改默认密码。") + # 自动迁移:如果后端当前存储的是 legacy hex(MD5 或 SHA256)的 digest, + # 且客户端此次提交了明文密码并且明文能通过校验,则将其替换为安全哈希(argon2 或 pbkdf2 回退)。 + try: + pwd_plain = str(post_data.get("password", "") or "") + s = str(stored_password_hash or "").strip().lower() + is_legacy_hex = (len(s) == 32 or len(s) == 64) and all( + ch in "0123456789abcdef" for ch in s + ) + if ( + pwd_plain + and is_legacy_hex + and verify_dashboard_password(pwd_plain, stored_password_hash) + ): + try: + new_hash = hash_dashboard_password_secure(pwd_plain) + self.config["dashboard"]["password"] = new_hash + # 保存到配置文件;如果保存失败只是记录警告但不阻止登录流程 + try: + self.config.save_config() + logger.info("已将旧版密码迁移为安全哈希格式。") + except Exception: + logger.warning("密码迁移:保存配置失败,迁移未持久化。") + except Exception as e: + logger.warning(f"密码迁移失败(生成哈希时出错):{e}") + except Exception: + logger.exception("密码迁移过程中发生意外错误") + return ( Response() .ok( @@ -79,7 +108,12 @@ class AuthRoute(Route): confirm_pwd = post_data.get("confirm_password", None) if confirm_pwd != new_pwd: return Response().error("两次输入的新密码不一致").__dict__ - self.config["dashboard"]["password"] = new_pwd + # Hash the new password before storing to ensure backend and CLI use the same format + try: + new_hash = hash_dashboard_password_secure(new_pwd) + except Exception as e: + return Response().error(f"Failed to hash new password: {e}").__dict__ + self.config["dashboard"]["password"] = new_hash if new_username: self.config["dashboard"]["username"] = new_username @@ -104,11 +138,37 @@ class AuthRoute(Route): stored_password_hash: str, post_data: dict | None, ) -> bool: + """ + Verify posted credentials against stored hash. + + Behavior: + - If client provided plaintext `password`, use `verify_dashboard_password` + which supports argon2, PBKDF2 fallback, and legacy hex digests. + - If only `password_md5` (hex) is provided, accept only when the stored + hash is the same legacy MD5 hex digest (backwards compatibility). + """ if not isinstance(post_data, dict): return False - provided_hashes = { - str(post_data.get("password", "") or "").strip().lower(), - str(post_data.get("password_md5", "") or "").strip().lower(), - } - provided_hashes.discard("") - return stored_password_hash in provided_hashes + + # Prefer plaintext verification when available + pwd_plain = str(post_data.get("password", "") or "") + pwd_md5 = str(post_data.get("password_md5", "") or "").strip().lower() + + if pwd_plain: + try: + return verify_dashboard_password(pwd_plain, stored_password_hash) + except Exception: + # Do not crash authentication on unexpected verifier errors; treat as mismatch. + return False + + # If only MD5 hex supplied by client, accept only if stored hash is the same legacy MD5 hex. + if pwd_md5: + try: + return ( + isinstance(stored_password_hash, str) + and stored_password_hash.strip().lower() == pwd_md5 + ) + except Exception: + return False + + return False diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 199916e80..2413a977d 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -396,11 +396,11 @@ class ConfigRoute(Route): """删除 provider_source,并更新关联的 providers""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() provider_source_id = post_data.get("id") if not provider_source_id: - return Response().error("缺少 provider_source_id").__dict__ + return Response().error("缺少 provider_source_id").to_json() provider_sources = self.config.get("provider_sources", []) target_idx = next( @@ -413,7 +413,7 @@ class ConfigRoute(Route): ) if target_idx == -1: - return Response().error("未找到对应的 provider source").__dict__ + return Response().error("未找到对应的 provider source").to_json() # 删除 provider_source del provider_sources[target_idx] @@ -430,23 +430,23 @@ class ConfigRoute(Route): save_config(self.config, self.config, is_core=True) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() - return Response().ok(message="删除 provider source 成功").__dict__ + return Response().ok(message="删除 provider source 成功").to_json() async def update_provider_source(self): """更新或新增 provider_source,并重载关联的 providers""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() new_source_config = post_data.get("config") or post_data original_id = post_data.get("original_id") if not original_id: - return Response().error("缺少 original_id").__dict__ + return Response().error("缺少 original_id").to_json() if not isinstance(new_source_config, dict): - return Response().error("缺少或错误的配置数据").__dict__ + return Response().error("缺少或错误的配置数据").to_json() # 确保配置中有 id 字段 if not new_source_config.get("id"): @@ -461,7 +461,7 @@ class ConfigRoute(Route): .error( f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.", ) - .__dict__ + .to_json() ) # 查找旧的 provider_source,若不存在则追加为新配置 @@ -491,7 +491,7 @@ class ConfigRoute(Route): save_config(self.config, self.config, is_core=True) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() # 重载受影响的 providers,使新的 source 配置生效 reload_errors = [] @@ -507,10 +507,10 @@ class ConfigRoute(Route): return ( Response() .error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors)) - .__dict__ + .to_json() ) - return Response().ok(message="更新 provider source 成功").__dict__ + return Response().ok(message="更新 provider source 成功").to_json() async def get_provider_template(self): provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( @@ -532,93 +532,95 @@ class ConfigRoute(Route): "providers": astrbot_config["provider"], "provider_sources": astrbot_config["provider_sources"], } - return Response().ok(data=data).__dict__ + return Response().ok(data=data).to_json() async def get_uc_table(self): """获取 UMOP 配置路由表""" - return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ + return Response().ok({"routing": self.ucr.umop_to_conf_id}).to_json() async def update_ucr_all(self): """更新 UMOP 配置路由表的全部内容""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() new_routing = post_data.get("routing", None) if not new_routing or not isinstance(new_routing, dict): - return Response().error("缺少或错误的路由表数据").__dict__ + return Response().error("缺少或错误的路由表数据").to_json() try: await self.ucr.update_routing_data(new_routing) - return Response().ok(message="更新成功").__dict__ + return Response().ok(message="更新成功").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ + return Response().error(f"更新路由表失败: {e!s}").to_json() async def update_ucr(self): """更新 UMOP 配置路由表""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() umo = post_data.get("umo", None) conf_id = post_data.get("conf_id", None) if not umo or not conf_id: - return Response().error("缺少 UMO 或配置文件 ID").__dict__ + return Response().error("缺少 UMO 或配置文件 ID").to_json() try: await self.ucr.update_route(umo, conf_id) - return Response().ok(message="更新成功").__dict__ + return Response().ok(message="更新成功").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ + return Response().error(f"更新路由表失败: {e!s}").to_json() async def delete_ucr(self): """删除 UMOP 配置路由表中的一项""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() umo = post_data.get("umo", None) if not umo: - return Response().error("缺少 UMO").__dict__ + return Response().error("缺少 UMO").to_json() try: if umo in self.ucr.umop_to_conf_id: del self.ucr.umop_to_conf_id[umo] await self.ucr.update_routing_data(self.ucr.umop_to_conf_id) - return Response().ok(message="删除成功").__dict__ + return Response().ok(message="删除成功").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"删除路由表项失败: {e!s}").__dict__ + return Response().error(f"删除路由表项失败: {e!s}").to_json() async def get_default_config(self): """获取默认配置文件""" metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__ + return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).to_json() async def get_abconf_list(self): """获取所有 AstrBot 配置文件的列表""" abconf_list = self.acm.get_conf_list() - return Response().ok({"info_list": abconf_list}).__dict__ + return Response().ok({"info_list": abconf_list}).to_json() async def create_abconf(self): """创建新的 AstrBot 配置文件""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() name = post_data.get("name", None) config = post_data.get("config", DEFAULT_CONFIG) try: conf_id = self.acm.create_conf(name=name, config=config) await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__ + return ( + Response().ok(message="创建成功", data={"conf_id": conf_id}).to_json() + ) except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_abconf(self): """获取指定 AstrBot 配置文件""" @@ -626,7 +628,7 @@ class ConfigRoute(Route): system_config = request.args.get("system_config", "0").lower() == "1" reload_from_file = request.args.get("reload_from_file", "0").lower() == "1" if not abconf_id and not system_config: - return Response().error("缺少配置文件 ID").__dict__ + return Response().error("缺少配置文件 ID").to_json() try: if system_config: @@ -640,7 +642,7 @@ class ConfigRoute(Route): metadata = ConfigMetadataI18n.convert_to_i18n_keys( CONFIG_METADATA_3_SYSTEM ) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ + return Response().ok({"config": abconf, "metadata": metadata}).to_json() if abconf_id is None: raise ValueError("abconf_id cannot be None") abconf = self.acm.confs[abconf_id] @@ -651,54 +653,54 @@ class ConfigRoute(Route): schema=abconf.schema, ) metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ + return Response().ok({"config": abconf, "metadata": metadata}).to_json() except (ValueError, KeyError) as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def delete_abconf(self): """删除指定 AstrBot 配置文件""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() conf_id = post_data.get("id") if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ + return Response().error("缺少配置文件 ID").to_json() try: success = self.acm.delete_conf(conf_id) if success: self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None) - return Response().ok(message="删除成功").__dict__ - return Response().error("删除失败").__dict__ + return Response().ok(message="删除成功").to_json() + return Response().error("删除失败").to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"删除配置文件失败: {e!s}").__dict__ + return Response().error(f"删除配置文件失败: {e!s}").to_json() async def update_abconf(self): """更新指定 AstrBot 配置文件信息""" post_data = await request.json if not post_data: - return Response().error("缺少配置数据").__dict__ + return Response().error("缺少配置数据").to_json() conf_id = post_data.get("id") if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ + return Response().error("缺少配置文件 ID").to_json() name = post_data.get("name") try: success = self.acm.update_conf_info(conf_id, name=name) if success: - return Response().ok(message="更新成功").__dict__ - return Response().error("更新失败").__dict__ + return Response().ok(message="更新成功").to_json() + return Response().error("更新失败").to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"更新配置文件失败: {e!s}").__dict__ + return Response().error(f"更新配置文件失败: {e!s}").to_json() async def _test_single_provider(self, provider): """辅助函数:测试单个 provider 的可用性""" @@ -746,7 +748,7 @@ class ConfigRoute(Route): # 记录更详细的traceback信息,但只在是严重错误时 if status_code == 500: log_fn(traceback.format_exc()) - return Response().error(message).__dict__ + return Response().error(message).to_json() async def check_one_provider_status(self): """API: check a single LLM Provider's status by id""" @@ -770,11 +772,11 @@ class ConfigRoute(Route): return ( Response() .error(f"Provider with id '{provider_id}' not found") - .__dict__ + .to_json() ) result = await self._test_single_provider(target) - return Response().ok(result).__dict__ + return Response().ok(result).to_json() except Exception as e: return self._error_response( @@ -787,13 +789,13 @@ class ConfigRoute(Route): # 否则返回指定 plugin_name 的插件配置 plugin_name = request.args.get("plugin_name", None) if not plugin_name: - return Response().ok(await self._get_astrbot_config()).__dict__ - return Response().ok(await self._get_plugin_config(plugin_name)).__dict__ + return Response().ok(await self._get_astrbot_config()).to_json() + return Response().ok(await self._get_plugin_config(plugin_name)).to_json() async def get_provider_config_list(self): provider_type = request.args.get("provider_type", None) if not provider_type: - return Response().error("缺少参数 provider_type").__dict__ + return Response().error("缺少参数 provider_type").to_json() provider_type_ls = provider_type.split(",") provider_list = [] ps = self.core_lifecycle.provider_manager.providers_config @@ -816,23 +818,23 @@ class ConfigRoute(Route): elif not ps_id and provider.get("provider_type", "") in provider_type_ls: # agent runner, embedding, etc provider_list.append(provider) - return Response().ok(provider_list).__dict__ + return Response().ok(provider_list).to_json() async def get_provider_model_list(self): """获取指定提供商的模型列表""" provider_id = request.args.get("provider_id", None) if not provider_id: - return Response().error("缺少参数 provider_id").__dict__ + return Response().error("缺少参数 provider_id").to_json() prov_mgr = self.core_lifecycle.provider_manager provider = prov_mgr.inst_map.get(provider_id, None) if not provider: - return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__ + return Response().error(f"未找到 ID 为 {provider_id} 的提供商").to_json() if not isinstance(provider, Provider): return ( Response() .error(f"提供商 {provider_id} 类型不支持获取模型列表") - .__dict__ + .to_json() ) try: @@ -850,17 +852,17 @@ class ConfigRoute(Route): "provider_id": provider_id, "model_metadata": metadata_map, } - return Response().ok(ret).__dict__ + return Response().ok(ret).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_embedding_dim(self): """获取嵌入模型的维度""" post_data = await request.json provider_config = post_data.get("provider_config", None) if not provider_config: - return Response().error("缺少参数 provider_config").__dict__ + return Response().error("缺少参数 provider_config").to_json() try: # 动态导入 EmbeddingProvider @@ -870,7 +872,7 @@ class ConfigRoute(Route): # 获取 provider 类型 provider_type = provider_config.get("type", None) if not provider_type: - return Response().error("provider_config 缺少 type 字段").__dict__ + return Response().error("provider_config 缺少 type 字段").to_json() # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器 if provider_type not in provider_cls_map: @@ -885,7 +887,7 @@ class ConfigRoute(Route): .error( "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志" ) - .__dict__ + .to_json() ) # 获取对应的 provider 类 @@ -893,21 +895,21 @@ class ConfigRoute(Route): return ( Response() .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ + .to_json() ) provider_metadata = provider_cls_map[provider_type] cls_type = provider_metadata.cls_type if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ + return Response().error(f"无法找到 {provider_type} 的类").to_json() # 实例化 provider inst = cls_type(provider_config, {}) # 检查是否是 EmbeddingProvider if not isinstance(inst, EmbeddingProvider): - return Response().error("提供商不是 EmbeddingProvider 类型").__dict__ + return Response().error("提供商不是 EmbeddingProvider 类型").to_json() init_fn = getattr(inst, "initialize", None) if inspect.iscoroutinefunction(init_fn): @@ -921,10 +923,10 @@ class ConfigRoute(Route): f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}", ) - return Response().ok({"embedding_dimensions": dim}).__dict__ + return Response().ok({"embedding_dimensions": dim}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ + return Response().error(f"获取嵌入维度失败: {e!s}").to_json() async def get_provider_source_models(self): """获取指定 provider_source 支持的模型列表 @@ -933,7 +935,7 @@ class ConfigRoute(Route): """ provider_source_id = request.args.get("source_id") if not provider_source_id: - return Response().error("缺少参数 source_id").__dict__ + return Response().error("缺少参数 source_id").to_json() try: from astrbot.core.provider.register import provider_cls_map @@ -950,13 +952,13 @@ class ConfigRoute(Route): return ( Response() .error(f"未找到 ID 为 {provider_source_id} 的 provider_source") - .__dict__ + .to_json() ) # 获取 provider 类型 provider_type = provider_source.get("type", None) if not provider_type: - return Response().error("provider_source 缺少 type 字段").__dict__ + return Response().error("provider_source 缺少 type 字段").to_json() try: self.core_lifecycle.provider_manager.dynamic_import_provider( @@ -964,28 +966,28 @@ class ConfigRoute(Route): ) except ImportError as e: logger.error(traceback.format_exc()) - return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__ + return Response().error(f"动态导入提供商适配器失败: {e!s}").to_json() # 获取对应的 provider 类 if provider_type not in provider_cls_map: return ( Response() .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ + .to_json() ) provider_metadata = provider_cls_map[provider_type] cls_type = provider_metadata.cls_type if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ + return Response().error(f"无法找到 {provider_type} 的类").to_json() # 检查是否是 Provider 类型 if not issubclass(cls_type, Provider): return ( Response() .error(f"提供商 {provider_type} 不支持获取模型列表") - .__dict__ + .to_json() ) # 临时实例化 provider @@ -1018,18 +1020,18 @@ class ConfigRoute(Route): return ( Response() .ok({"models": models, "model_metadata": metadata_map}) - .__dict__ + .to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"获取模型列表失败: {e!s}").__dict__ + return Response().error(f"获取模型列表失败: {e!s}").to_json() async def get_platform_list(self): """获取所有平台的列表""" platform_list = [] for platform in self.config["platform"]: platform_list.append(platform) - return Response().ok({"platforms": platform_list}).__dict__ + return Response().ok({"platforms": platform_list}).to_json() async def post_astrbot_configs(self): data = await request.json @@ -1050,11 +1052,11 @@ class ConfigRoute(Route): # Non-blocking Bay connectivity check warning = await _validate_neo_connectivity(config) if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ - return Response().ok(None, "保存成功~").__dict__ + return Response().ok(None, f"保存成功。{warning}").to_json() + return Response().ok(None, "保存成功~").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def post_plugin_configs(self): post_configs = await request.json @@ -1065,10 +1067,10 @@ class ConfigRoute(Route): return ( Response() .ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。") - .__dict__ + .to_json() ) except Exception as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: for plugin_md in star_registry: @@ -1104,13 +1106,13 @@ class ConfigRoute(Route): """上传文件到插件数据目录(用于某个 file 类型配置项)。""" try: - scope, name, key_path, md, config = self._resolve_config_file_scope() + _scope, name, key_path, _md, config = self._resolve_config_file_scope() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() meta = get_schema_item(getattr(config, "schema", None), key_path) if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ + return Response().error("Config item not found or not file type").to_json() file_types = meta.get("file_types") allowed_exts: list[str] = [] @@ -1121,14 +1123,14 @@ class ConfigRoute(Route): files = await request.files if not files: - return Response().error("No files uploaded").__dict__ + return Response().error("No files uploaded").to_json() storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path())) plugin_root_path = _resolve_path(storage_root_path / name) try: plugin_root_path.relative_to(storage_root_path) except ValueError: - return Response().error("Invalid name parameter").__dict__ + return Response().error("Invalid name parameter").to_json() plugin_root_path.mkdir(parents=True, exist_ok=True) uploaded: list[str] = [] @@ -1174,10 +1176,10 @@ class ConfigRoute(Route): if errors else "Upload failed", ) - .__dict__ + .to_json() ) - return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ + return Response().ok({"uploaded": uploaded, "errors": errors}).to_json() async def delete_config_file(self): """删除插件数据目录中的文件。""" @@ -1185,35 +1187,35 @@ class ConfigRoute(Route): scope = request.args.get("scope") or "plugin" name = request.args.get("name") if not name: - return Response().error("Missing name parameter").__dict__ + return Response().error("Missing name parameter").to_json() if scope != "plugin": - return Response().error(f"Unsupported scope: {scope}").__dict__ + return Response().error(f"Unsupported scope: {scope}").to_json() data = await request.get_json() rel_path = data.get("path") if isinstance(data, dict) else None rel_path = normalize_rel_path(rel_path) if not rel_path or not rel_path.startswith("files/"): - return Response().error("Invalid path parameter").__dict__ + return Response().error("Invalid path parameter").to_json() md = self._get_plugin_metadata_by_name(name) if not md: - return Response().error(f"Plugin {name} not found").__dict__ + return Response().error(f"Plugin {name} not found").to_json() storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path())) plugin_root_path = _resolve_path(storage_root_path / name) try: plugin_root_path.relative_to(storage_root_path) except ValueError: - return Response().error("Invalid name parameter").__dict__ + return Response().error("Invalid name parameter").to_json() target_path = _resolve_path(plugin_root_path / rel_path) try: target_path.relative_to(plugin_root_path) except ValueError: - return Response().error("Invalid path parameter").__dict__ + return Response().error("Invalid path parameter").to_json() if target_path.is_file(): target_path.unlink() - return Response().ok(None, "Deleted").__dict__ + return Response().ok(None, "Deleted").to_json() async def get_config_file_list(self): """获取配置项对应目录下的文件列表。""" @@ -1221,28 +1223,28 @@ class ConfigRoute(Route): try: _, name, key_path, _, config = self._resolve_config_file_scope() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() meta = get_schema_item(getattr(config, "schema", None), key_path) if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ + return Response().error("Config item not found or not file type").to_json() storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path())) plugin_root_path = _resolve_path(storage_root_path / name) try: plugin_root_path.relative_to(storage_root_path) except ValueError: - return Response().error("Invalid name parameter").__dict__ + return Response().error("Invalid name parameter").to_json() folder = config_key_to_folder(key_path) target_dir = _resolve_path(plugin_root_path / "files" / folder) try: target_dir.relative_to(plugin_root_path) except ValueError: - return Response().error("Invalid path parameter").__dict__ + return Response().error("Invalid path parameter").to_json() if not target_dir.exists() or not target_dir.is_dir(): - return Response().ok({"files": []}).__dict__ + return Response().ok({"files": []}).to_json() files: list[str] = [] for path in target_dir.rglob("*"): @@ -1255,7 +1257,7 @@ class ConfigRoute(Route): if rel_path.startswith("files/"): files.append(rel_path) - return Response().ok({"files": files}).__dict__ + return Response().ok({"files": files}).to_json() async def post_new_platform(self): new_platform_config = await request.json @@ -1270,8 +1272,8 @@ class ConfigRoute(Route): new_platform_config, ) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增平台配置成功~").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "新增平台配置成功~").to_json() async def post_new_provider(self): new_provider_config = await request.json @@ -1281,18 +1283,18 @@ class ConfigRoute(Route): new_provider_config ) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增服务提供商配置成功").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "新增服务提供商配置成功").to_json() async def post_update_platform(self): update_platform_config = await request.json origin_platform_id = update_platform_config.get("id", None) new_config = update_platform_config.get("config", None) if not origin_platform_id or not new_config: - return Response().error("参数错误").__dict__ + return Response().error("参数错误").to_json() if origin_platform_id != new_config.get("id", None): - return Response().error("机器人名称不允许修改").__dict__ + return Response().error("机器人名称不允许修改").to_json() # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid ensure_platform_webhook_config(new_config) @@ -1302,29 +1304,29 @@ class ConfigRoute(Route): self.config["platform"][i] = new_config break else: - return Response().error("未找到对应平台").__dict__ + return Response().error("未找到对应平台").to_json() try: save_config(self.config, self.config, is_core=True) await self.core_lifecycle.platform_manager.reload(new_config) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新平台配置成功~").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "更新平台配置成功~").to_json() async def post_update_provider(self): update_provider_config = await request.json origin_provider_id = update_provider_config.get("id", None) new_config = update_provider_config.get("config", None) if not origin_provider_id or not new_config: - return Response().error("参数错误").__dict__ + return Response().error("参数错误").to_json() try: await self.core_lifecycle.provider_manager.update_provider( origin_provider_id, new_config ) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新成功,已经实时生效~").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "更新成功,已经实时生效~").to_json() async def post_delete_platform(self): platform_id = await request.json @@ -1334,33 +1336,33 @@ class ConfigRoute(Route): del self.config["platform"][i] break else: - return Response().error("未找到对应平台").__dict__ + return Response().error("未找到对应平台").to_json() try: save_config(self.config, self.config, is_core=True) await self.core_lifecycle.platform_manager.terminate_platform(platform_id) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除平台配置成功~").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "删除平台配置成功~").to_json() async def post_delete_provider(self): provider_id = await request.json provider_id = provider_id.get("id", "") if not provider_id: - return Response().error("缺少参数 id").__dict__ + return Response().error("缺少参数 id").to_json() try: await self.core_lifecycle.provider_manager.delete_provider( provider_id=provider_id ) except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除成功,已经实时生效。").__dict__ + return Response().error(str(e)).to_json() + return Response().ok(None, "删除成功,已经实时生效。").to_json() async def get_llm_tools(self): """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" tool_mgr = self.core_lifecycle.provider_manager.llm_tools tools = tool_mgr.get_func_desc_openai_style() - return Response().ok(tools).__dict__ + return Response().ok(tools).to_json() async def _register_platform_logo(self, platform, platform_default_tmpl) -> None: """注册平台logo文件并生成访问令牌""" diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index d7c596f68..30e8ffc7a 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -646,7 +646,7 @@ class LiveChatRoute(Route): { "ct": "chat", "t": "error", - "data": f"处理失败: {str(e)}", + "data": f"处理失败: {e!s}", "code": "PROCESSING_ERROR", }, ) @@ -911,7 +911,7 @@ class LiveChatRoute(Route): except Exception as e: logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True) - await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"}) + await websocket.send_json({"t": "error", "data": f"处理失败: {e!s}"}) finally: session.is_processing = False diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 488171fd7..78bd378f9 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -490,25 +490,22 @@ class AstrBotDashboard: or ssl_config.get("ca_certs", "") ) - cert_path = anyio.Path(cert_file).expanduser() - key_path = anyio.Path(key_file).expanduser() - if not cert_file or not key_file: - raise ValueError( - "dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。", - ) - if not await cert_path.is_file(): - raise ValueError(f"SSL 证书文件不存在: {cert_path}") - if not await key_path.is_file(): - raise ValueError(f"SSL 私钥文件不存在: {key_path}") + if cert_file and key_file: + cert_path = await anyio.Path(cert_file).expanduser() + key_path = await anyio.Path(key_file).expanduser() + if not cert_path.is_file(): + raise ValueError(f"SSL 证书文件不存在: {cert_path}") + if not key_path.is_file(): + raise ValueError(f"SSL 私钥文件不存在: {key_path}") - config.certfile = str(await cert_path.resolve()) - config.keyfile = str(await key_path.resolve()) + config.certfile = str(cert_path.resolve()) + config.keyfile = str(key_path.resolve()) if ca_certs: - ca_path = anyio.Path(ca_certs).expanduser() - if not await ca_path.is_file(): + ca_path = await anyio.Path(ca_certs).expanduser() + if not ca_path.is_file(): raise ValueError(f"SSL CA 证书文件不存在: {ca_path}") - config.ca_certs = str(await ca_path.resolve()) + config.ca_certs = str(ca_path.resolve()) # 根据配置决定是否禁用访问日志 disable_access_log = dashboard_config.get("disable_access_log", True) diff --git a/astrbot/runtime_bootstrap.py b/astrbot/runtime_bootstrap.py index cd269e615..1e9d109d6 100644 --- a/astrbot/runtime_bootstrap.py +++ b/astrbot/runtime_bootstrap.py @@ -47,4 +47,4 @@ def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool: def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool: - return configure_runtime_ca_bundle(log_obj=log_obj) \ No newline at end of file + return configure_runtime_ca_bundle(log_obj=log_obj) diff --git a/main.py b/main.py index 7a9154301..638fbc198 100644 --- a/main.py +++ b/main.py @@ -1,133 +1,13 @@ import argparse import asyncio -import mimetypes -import os import sys from pathlib import Path -import anyio - -import runtime_bootstrap -from astrbot.core import LogBroker, LogManager, db_helper, logger -from astrbot.core.config.default import VERSION -from astrbot.core.initial_loader import InitialLoader -from astrbot.core.utils.astrbot_path import ( - get_astrbot_config_path, - get_astrbot_data_path, - get_astrbot_knowledge_base_path, - get_astrbot_plugin_path, - get_astrbot_root, - get_astrbot_site_packages_path, - get_astrbot_temp_path, -) -from astrbot.core.utils.io import ( - download_dashboard, - get_dashboard_version, -) - -runtime_bootstrap.initialize_runtime_bootstrap() - +from astrbot.__main__ import LogBroker, LogManager, check_env, logger, main_async # 将父目录添加到 sys.path sys.path.append(Path(__file__).parent.as_posix()) -logo_tmpl = r""" - ___ _______.___________..______ .______ ______ .___________. - / \ / | || _ \ | _ \ / __ \ | | - / ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----` - / /_\ \ \ \ | | | / | _ < | | | | | | - / _____ \ .----) | | | | |\ \----.| |_) | | `--' | | | -/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__| - -""" - - -def check_env() -> None: - if not (sys.version_info.major == 3 and sys.version_info.minor >= 10): - logger.error("请使用 Python3.10+ 运行本项目。") - exit() - - astrbot_root = get_astrbot_root() - if astrbot_root not in sys.path: - sys.path.insert(0, astrbot_root) - - site_packages_path = get_astrbot_site_packages_path() - if site_packages_path not in sys.path: - sys.path.insert(0, site_packages_path) - - os.makedirs(get_astrbot_config_path(), exist_ok=True) - os.makedirs(get_astrbot_plugin_path(), exist_ok=True) - os.makedirs(get_astrbot_temp_path(), exist_ok=True) - os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True) - os.makedirs(site_packages_path, exist_ok=True) - - # 针对问题 #181 的临时解决方案 - mimetypes.add_type("text/javascript", ".js") - mimetypes.add_type("text/javascript", ".mjs") - mimetypes.add_type("application/json", ".json") - - -async def check_dashboard_files(webui_dir: str | None = None): - """下载管理面板文件""" - # 指定webui目录 - if webui_dir: - if await anyio.Path(webui_dir).exists(): - logger.info(f"使用指定的 WebUI 目录: {webui_dir}") - return webui_dir - logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。") - - data_dist_path = os.path.join(get_astrbot_data_path(), "dist") - if await anyio.Path(data_dist_path).exists(): - v = await get_dashboard_version() - if v is not None: - # 存在文件 - if v == f"v{VERSION}": - logger.info("WebUI 版本已是最新。") - else: - logger.warning( - f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。", - ) - return data_dist_path - - logger.info( - "开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。", - ) - - try: - await download_dashboard(version=f"v{VERSION}", latest=False) - except Exception as e: - logger.warning( - f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。" - ) - try: - await download_dashboard(latest=True) - except Exception as e: - logger.critical(f"下载管理面板文件失败: {e}。") - return None - - logger.info("管理面板下载完成。") - return data_dist_path - - -async def main_async(webui_dir_arg: str | None) -> None: - """主异步入口""" - # 检查仪表板文件 - webui_dir = await check_dashboard_files(webui_dir_arg) - if webui_dir is None: - logger.warning( - "管理面板文件检查失败,WebUI 功能将不可用。" - "请检查网络连接或手动指定 --webui-dir 参数。" - ) - - db = db_helper - - # 打印 logo - logger.info(logo_tmpl) - - core_lifecycle = InitialLoader(db, log_broker) - core_lifecycle.webui_dir = webui_dir - await core_lifecycle.start() - if __name__ == "__main__": parser = argparse.ArgumentParser(description="AstrBot") diff --git a/runtime_bootstrap.py b/runtime_bootstrap.py index 1e9d109d6..5ebe0a458 100644 --- a/runtime_bootstrap.py +++ b/runtime_bootstrap.py @@ -1,50 +1,3 @@ -import logging -import ssl -from typing import Any +from astrbot.runtime_bootstrap import initialize_runtime_bootstrap -import aiohttp.connector as aiohttp_connector - -from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi - -logger = logging.getLogger(__name__) - - -def _try_patch_aiohttp_ssl_context( - ssl_context: ssl.SSLContext, - log_obj: Any | None = None, -) -> bool: - log = log_obj or logger - attr_name = "_SSL_CONTEXT_VERIFIED" - - if not hasattr(aiohttp_connector, attr_name): - log.warning( - "aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.", - ) - return False - - current_value = getattr(aiohttp_connector, attr_name, None) - if current_value is not None and not isinstance(current_value, ssl.SSLContext): - log.warning( - "aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.", - ) - return False - - setattr(aiohttp_connector, attr_name, ssl_context) - log.info("Configured aiohttp verified SSL context with system+certifi trust chain.") - return True - - -def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool: - log = log_obj or logger - - try: - log.info("Bootstrapping runtime CA bundle.") - ssl_context = build_ssl_context_with_certifi(log_obj=log) - return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log) - except Exception as exc: - log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc) - return False - - -def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool: - return configure_runtime_ca_bundle(log_obj=log_obj) +__all__ = ["initialize_runtime_bootstrap"] diff --git a/scripts/hatch_build.py b/scripts/hatch_build.py index 431465b54..f01d4e54f 100644 --- a/scripts/hatch_build.py +++ b/scripts/hatch_build.py @@ -21,6 +21,7 @@ import sys from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from loguru import logger class CustomBuildHook(BuildHookInterface): @@ -38,15 +39,15 @@ class CustomBuildHook(BuildHookInterface): dist_target = root / "astrbot" / "dashboard" / "dist" if not dashboard_src.exists(): - print( - "[hatch_build] 'dashboard/' directory not found – skipping dashboard build.", + logger.warning( + "[hatch_build] 'dashboard/' directory not found : skipping dashboard build.", file=sys.stderr, ) return # ── Install Node dependencies if node_modules is absent ───────────── if not (dashboard_src / "node_modules").exists(): - print("[hatch_build] Installing dashboard Node dependencies...") + logger.info("[hatch_build] Installing dashboard Node dependencies...") subprocess.run( ["npm", "install"], cwd=dashboard_src, @@ -54,7 +55,7 @@ class CustomBuildHook(BuildHookInterface): ) # ── Build the Vue/Vite dashboard ────────────────────────────────────── - print("[hatch_build] Building Vue dashboard (npm run build)...") + logger.info("[hatch_build] Building Vue dashboard (npm run build)...") subprocess.run( ["npm", "run", "build"], cwd=dashboard_src, @@ -62,8 +63,8 @@ class CustomBuildHook(BuildHookInterface): ) if not dist_src.exists(): - print( - "[hatch_build] dashboard/dist not found after build – skipping copy.", + logger.warning( + "[hatch_build] dashboard/dist not found after build: skipping copy.", file=sys.stderr, ) return @@ -72,4 +73,6 @@ class CustomBuildHook(BuildHookInterface): if dist_target.exists(): shutil.rmtree(dist_target) shutil.copytree(dist_src, dist_target) - print(f"[hatch_build] Dashboard dist copied → {dist_target.relative_to(root)}") + logger.info( + f"[hatch_build] Dashboard dist copied → {dist_target.relative_to(root)}" + ) diff --git a/typings/faiss/__init__.pyi b/typings/faiss/__init__.pyi index 6f2bace36..bdb0e8d44 100644 --- a/typings/faiss/__init__.pyi +++ b/typings/faiss/__init__.pyi @@ -1,10 +1,3 @@ -"""Minimal type stubs for faiss used in this project. - -This file only exposes a small subset of the faiss API that the -project uses, including the runtime-monkeypatched signatures such as -`Index.add_with_ids` so Pyright/Pylance stops reporting false positives. -""" - from typing import Any, overload import numpy as np @@ -69,14 +62,9 @@ class IndexBinary(Index): class InvertedLists: def __len__(self) -> int: ... -class AdditiveQuantizer: - pass - -class Quantizer: - pass - -class VectorTransform: - pass +class AdditiveQuantizer: ... +class Quantizer: ... +class VectorTransform: ... # SWIG-provided downcast helpers (present in some faiss Python builds). def downcast_IndexBinary(obj: Any) -> IndexBinary: ...