diff --git a/docs/runtime-stability-runbook.md b/docs/runtime-stability-runbook.md new file mode 100644 index 0000000000..e3b477496e --- /dev/null +++ b/docs/runtime-stability-runbook.md @@ -0,0 +1,173 @@ +# AstrBot 本地稳定运行排障说明 + +这份说明记录当前本地 AstrBot + NapCat / OneBot + QQTools 浏览器搜索的稳定运行方式。 + +## 启动 + +在 PowerShell 中执行: + +```powershell +Set-Location "D:\Project files\AstrBotCore" +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" +``` + +如果已有旧实例占用 `6185` 或 `6199`,脚本会拒绝重复启动,并显示占用端口的 PID。 + +`start_astrbot.bat` 和 `scripts\start_with_logs.ps1` 都只是兼容入口,会转发到 `start_astrbot.ps1`,不会绕过配置守护、健康检查和端口保护。 + +## 安全重启 + +推荐用脚本自动停掉明确属于当前 AstrBotCore 的旧进程,再启动新实例: + +```powershell +Set-Location "D:\Project files\AstrBotCore" +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" -StopExisting +``` + +默认不带 `-StopExisting` 时,脚本只会显示停止计划,不会停进程。 + +## 配置和运行态检查 + +完整检查: + +```powershell +Set-Location "D:\Project files\AstrBotCore" +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" -FullCheck +``` + +`-FullCheck` 等价于 `-CheckOnly -RuntimeCheck -BrowserCheck -StartupSignals`。 + +浏览器搜索底座检查: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" -CheckOnly -BrowserSmoke +``` + +`BrowserSmoke` 只启动一次本地 headless Chrome 打开 `about:blank`,不访问外网。 + +## 当前插件整合策略 + +主回复链路: + +- SpectreCore 负责主动/普通回复。 +- QQTools 提供本地浏览器搜索工具。 +- Meme Manager 保留低频表情回复。 +- SelfLearning 保留好感度、情绪、基础消息采集和社交上下文。 + +关闭或降级的慢路径: + +- LivingMemory 已禁用:当前没有 Embedding Provider,加载时会等待并触发 30 秒超时。 +- Bilibili 已禁用:当前没有凭据,会产生定时告警。 +- SelfLearning 的黑话学习、风格学习、ML 分析、记忆图、知识图关闭,避免每条消息触发重流程。 +- Tavily / BoCha / Brave / Firecrawl / Exa / Baidu 这类 API-key 搜索工具已禁用,避免没有 key 时模型误调用。 + +网络搜索走 QQTools 浏览器工具: + +- `browser_open` +- `browser_search` +- `browser_click` +- `browser_input` +- `browser_scroll` +- `browser_get_link` + +## 预期 OK 信号 + +重启后运行检查中应看到: + +```text +OK plugins.disabled_slow +OK tools.api_search_disabled +OK tools.browser_available +OK qqtools.browser +OK self_learning.balanced +OK browser.playwright +OK browser.executable +OK browser.proxy +OK runtime.dashboard_port +OK runtime.onebot_ws_port +``` + +发送 QQ 测试消息后,启动信号检查应尽量看到: + +```text +aiocqhttp(OneBot v11) 适配器已连接 +Browser tools registered +好感度管理服务启动成功 +``` + +## 不应再出现的坏信号 + +```text +等待插件初始化超时 +等待 Provider 就绪 +Tavily API key is not configured +RESOURCE_EXHAUSTED +``` + +如果这些再次出现,先运行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" -FullCheck +``` + +## 当前保留的 WARN + +`platform.id_whitelist` 为空时不是故障。AstrBot 的白名单检查源码会在列表为空时直接跳过过滤,所以它等价于“不限制会话 ID”。 + +如果后续只想允许固定私聊或固定群触发,再把会话 ID 写入 `platform_settings.id_whitelist`。 + +`runtime.file_log` 如果提示: + +```text +astrbot.log does not exist yet +``` + +说明当前运行的是旧进程,还没重启加载文件日志配置。执行 `-StopExisting` 重启即可。 + +## browser_search restart signal + +After the browser-search integration is installed, `-FullCheck` should show: + +```text +OK startup.qqtools_browser_search_registered +``` + +If it shows WARN, the current AstrBot process was started before `browser_search` was registered. Restart with: + +```powershell +PowerShell -NoProfile -ExecutionPolicy Bypass -File "D:\Project files\AstrBotCore\start_astrbot.ps1" -StopExisting +``` + +`runtime.restart_needed` compares the running AstrBot process start time with watched plugin files. If a plugin file is newer than the process, restart before testing QQ behavior. + +## browser_search QQ test + +After restarting, send a QQ message such as: + +```text +帮我查一下今天的新闻 +``` + +Then verify the search path with: + +```powershell +PowerShell -NoProfile -ExecutionPolicy Bypass -File "D:\Project files\AstrBotCore\start_astrbot.ps1" -CheckOnly -SearchSignals +``` + +Expected signal: + +```text +OK search.browser_search_used +``` + +You can also run the combined post-restart check after sending the QQ test message: + +```powershell +PowerShell -NoProfile -ExecutionPolicy Bypass -File "D:\Project files\AstrBotCore\start_astrbot.ps1" -PostRestartCheck -FailOnWarn +``` + +For final acceptance, run the smoke version. It also launches a headless browser once: + +```powershell +PowerShell -NoProfile -ExecutionPolicy Bypass -File "D:\Project files\AstrBotCore\start_astrbot.ps1" -PostRestartSmokeCheck -FailOnWarn +``` diff --git a/scripts/diagnose_runtime_health.py b/scripts/diagnose_runtime_health.py new file mode 100644 index 0000000000..a489d898ef --- /dev/null +++ b/scripts/diagnose_runtime_health.py @@ -0,0 +1,1117 @@ +from __future__ import annotations + +import argparse +import asyncio +import csv +import importlib.util +import io +import json +import os +import socket +import sqlite3 +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +OWNER_QQ = "2831304142" +DB_PATH = ROOT / "data/data_v4.db" +REQUIRED_DISABLED_PLUGINS = { + "data.plugins.astrbot_plugin_livingmemory.main", + "data.plugins.astrbot_plugin_bilibili.main", +} +REQUIRED_DISABLED_API_SEARCH_TOOLS = { + "web_search_tavily", + "tavily_extract_web_page", + "web_search_bocha", + "web_search_brave", + "web_search_firecrawl", + "firecrawl_extract_web_page", + "web_search_baidu", + "web_search_exa", + "exa_get_contents", +} +REQUIRED_BROWSER_TOOLS = { + "browser_search", + "browser_open", + "browser_click", + "browser_input", + "browser_scroll", + "browser_get_link", +} + + +@dataclass +class Check: + name: str + status: str + detail: str + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def load_preference(key: str, default: Any) -> Any: + if not DB_PATH.exists(): + return default + with sqlite3.connect(DB_PATH) as conn: + row = conn.execute( + "select value from preferences where scope=? and scope_id=? and key=?", + ("global", "global", key), + ).fetchone() + if not row: + return default + try: + payload = json.loads(row[0]) + except json.JSONDecodeError: + return default + return payload.get("val", default) + + +def ok(name: str, detail: str) -> Check: + return Check(name, "OK", detail) + + +def warn(name: str, detail: str) -> Check: + return Check(name, "WARN", detail) + + +def fail(name: str, detail: str) -> Check: + return Check(name, "FAIL", detail) + + +def is_port_open(host: str, port: int, timeout: float = 0.75) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def get_process_name(pid: str) -> str: + try: + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + + if result.returncode != 0 or not result.stdout.strip(): + return "" + try: + rows = list(csv.reader(io.StringIO(result.stdout))) + except csv.Error: + return "" + if not rows or not rows[0] or "INFO:" in rows[0][0]: + return "" + return rows[0][0] + + +def get_port_owner_label(port: int) -> str: + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "tcp"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + + if result.returncode != 0: + return "" + + suffixes = (f":{port}", f"]:{port}") + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 5 or parts[0].upper() != "TCP": + continue + local_address, state, pid = parts[1], parts[3].upper(), parts[-1] + if state != "LISTENING" or not local_address.endswith(suffixes): + continue + process_name = get_process_name(pid) + return f" (PID {pid}, {process_name})" if process_name else f" (PID {pid})" + return "" + + +def get_listening_port_owner_pid(port: int) -> str: + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "tcp"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + + if result.returncode != 0: + return "" + + suffixes = (f":{port}", f"]:{port}") + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 5 or parts[0].upper() != "TCP": + continue + local_address, state, pid = parts[1], parts[3].upper(), parts[-1] + if state == "LISTENING" and local_address.endswith(suffixes): + return pid + return "" + + +def get_process_start_timestamp(pid: str) -> float | None: + if not pid: + return None + command = ( + "$p=Get-Process -Id " + + str(pid) + + " -ErrorAction Stop; " + + "([DateTimeOffset]$p.StartTime).ToUnixTimeSeconds()" + ) + try: + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=3, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + + if result.returncode != 0: + return None + value = result.stdout.strip().splitlines()[-1:] or [""] + try: + return float(value[0]) + except ValueError: + return None + + +def get_runtime_owner_start_timestamps() -> list[float]: + try: + data = load_json(ROOT / "data/cmd_config.json") + except Exception: + return [] + + ports: set[int] = set() + dashboard = data.get("dashboard", {}) + if dashboard.get("enable") is True: + ports.add(int(dashboard.get("port", 6185) or 6185)) + for platform in data.get("platform", []) or []: + if platform.get("type") == "aiocqhttp" and platform.get("enable") is True: + ports.add(int(platform.get("ws_reverse_port", 6199) or 6199)) + + pids = { + pid for pid in (get_listening_port_owner_pid(port) for port in ports) if pid + } + return [ + ts + for ts in (get_process_start_timestamp(pid) for pid in pids) + if ts is not None + ] + + +def resolve_data_path(configured_path: str | None, default_relative_path: str) -> Path: + raw_path = configured_path or default_relative_path + path = Path(raw_path) + if path.is_absolute(): + return path + return ROOT / "data" / path + + +def find_system_browser_executable() -> Path | None: + candidates = [ + os.environ.get("QQTOOLS_BROWSER_EXECUTABLE", ""), + str( + Path(os.environ.get("ProgramFiles", "")) + / "Google" + / "Chrome" + / "Application" + / "chrome.exe" + ), + str( + Path(os.environ.get("ProgramFiles(x86)", "")) + / "Google" + / "Chrome" + / "Application" + / "chrome.exe" + ), + str( + Path(os.environ.get("LocalAppData", "")) + / "Google" + / "Chrome" + / "Application" + / "chrome.exe" + ), + str( + Path(os.environ.get("ProgramFiles", "")) + / "Microsoft" + / "Edge" + / "Application" + / "msedge.exe" + ), + str( + Path(os.environ.get("ProgramFiles(x86)", "")) + / "Microsoft" + / "Edge" + / "Application" + / "msedge.exe" + ), + str( + Path(os.environ.get("LocalAppData", "")) + / "Microsoft" + / "Edge" + / "Application" + / "msedge.exe" + ), + ] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return Path(candidate) + return None + + +def browser_proxy() -> str: + return ( + os.environ.get("QQTOOLS_BROWSER_PROXY") + or os.environ.get("HTTPS_PROXY") + or os.environ.get("HTTP_PROXY") + or os.environ.get("ALL_PROXY") + or os.environ.get("https_proxy") + or os.environ.get("http_proxy") + or os.environ.get("all_proxy") + or "" + ).strip() + + +async def run_browser_smoke(browser_path: Path | None, proxy: str) -> str: + from playwright.async_api import async_playwright + + launch_kwargs: dict[str, Any] = {"headless": True} + if browser_path: + launch_kwargs["executable_path"] = str(browser_path) + if proxy: + proxy_url = proxy if "://" in proxy else f"http://{proxy}" + launch_kwargs["proxy"] = {"server": proxy_url} + + async with async_playwright() as playwright: + browser = await playwright.chromium.launch( + **launch_kwargs, + args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + ) + try: + page = await browser.new_page() + await page.goto("about:blank", wait_until="domcontentloaded") + title = await page.title() + return title + finally: + await browser.close() + + +def check_core() -> list[Check]: + path = ROOT / "data/cmd_config.json" + data = load_json(path) + provider_settings = data.get("provider_settings", {}) + ltm = data.get("provider_ltm_settings", {}) + provider_sources = data.get("provider_sources", []) or [] + providers = data.get("provider", []) or [] + checks: list[Check] = [] + + default_provider = provider_settings.get("default_provider_id") + checks.append( + ok("core.default_provider", str(default_provider)) + if default_provider == "google_gemini/gemini-2.5-flash" + else fail( + "core.default_provider", + f"expected google_gemini/gemini-2.5-flash, got {default_provider!r}", + ) + ) + + web_search = provider_settings.get("web_search") + checks.append( + ok( + "core.web_search", + "built-in web_search is disabled; QQTools browser is used", + ) + if web_search is False + else fail( + "core.web_search", + f"built-in web_search should be false, got {web_search!r}", + ) + ) + + log_file_enable = data.get("log_file_enable") + checks.append( + ok("core.log_file", "file logging enabled") + if log_file_enable is True + else warn( + "core.log_file", + f"log_file_enable should be true for troubleshooting, got {log_file_enable!r}", + ) + ) + + persona = provider_settings.get("default_personality") + checks.append( + ok("core.persona", str(persona)) + if persona == "catgirl" + else fail("core.persona", f"expected catgirl, got {persona!r}") + ) + + active_reply = (ltm.get("active_reply") or {}).get("enable") + checks.append( + ok("core.active_reply", "disabled") + if active_reply is False + else fail( + "core.active_reply", + f"provider active_reply should be false, got {active_reply!r}", + ) + ) + + platform_settings = data.get("platform_settings", {}) + id_whitelist_enabled = platform_settings.get("enable_id_white_list") + id_whitelist = platform_settings.get("id_whitelist") or [] + checks.append( + ok( + "platform.id_whitelist", + "empty list means whitelist filtering is skipped by AstrBot", + ) + if id_whitelist_enabled is True and not id_whitelist + else ok( + "platform.id_whitelist", + f"enabled={id_whitelist_enabled!r}, entries={len(id_whitelist)}", + ) + ) + + gemini_enabled = [ + p.get("id") + for p in providers + if str(p.get("id", "")).startswith("google_gemini/") and bool(p.get("enable")) + ] + gemini_sources_enabled = [ + s.get("id") + for s in provider_sources + if ( + str(s.get("id", "")).startswith("google_gemini") + or str(s.get("provider", "")) == "google" + ) + and bool(s.get("enable")) + ] + vision_provider = next( + ( + provider + for provider in providers + if provider.get("id") == "google_gemini/gemini-2.5-flash" + ), + {}, + ) + checks.append( + ok("core.vision_provider", "Gemini 2.5 Flash is enabled for image input") + if gemini_enabled == ["google_gemini/gemini-2.5-flash"] + and gemini_sources_enabled == ["google_gemini"] + and "image" in (vision_provider.get("modalities") or []) + else fail( + "core.vision_provider", + "expected enabled Gemini 2.5 Flash with image modality; " + f"providers={gemini_enabled}, sources={gemini_sources_enabled}, " + f"modalities={vision_provider.get('modalities')!r}", + ) + ) + return checks + + +def check_plugin_configs() -> list[Check]: + checks: list[Check] = [] + + disabled_plugins = set(load_preference("inactivated_plugins", [])) + disabled_tools = set(load_preference("inactivated_llm_tools", [])) + missing_disabled = sorted(REQUIRED_DISABLED_PLUGINS - disabled_plugins) + checks.append( + ok("plugins.disabled_slow", "LivingMemory and Bilibili are disabled") + if not missing_disabled + else fail( + "plugins.disabled_slow", + f"slow/no-credential plugins still active: {missing_disabled}", + ) + ) + + missing_disabled_tools = sorted(REQUIRED_DISABLED_API_SEARCH_TOOLS - disabled_tools) + checks.append( + ok( + "tools.api_search_disabled", + "API-key web search tools are disabled; QQTools browser is used", + ) + if not missing_disabled_tools + else fail( + "tools.api_search_disabled", + f"API-key web search tools still active: {missing_disabled_tools}", + ) + ) + disabled_browser_tools = sorted(REQUIRED_BROWSER_TOOLS & disabled_tools) + checks.append( + ok("tools.browser_available", "required browser tools are not disabled") + if not disabled_browser_tools + else fail( + "tools.browser_available", + f"required browser tools are disabled: {disabled_browser_tools}", + ) + ) + + spectrecore = load_json(ROOT / "data/config/spectrecore_config.json") + checks.append( + ok("spectrecore.main_reply", "tools enabled, groups/private enabled") + if spectrecore.get("use_func_tool") is True + and spectrecore.get("enable_all_groups") is True + and spectrecore.get("enabled_private") is True + else fail( + "spectrecore.main_reply", + "SpectreCore should be the main reply plugin with tools enabled", + ) + ) + + qqtools = load_json(ROOT / "data/config/astrbot_plugin_qq_tools_config.json") + browser = qqtools.get("browser_config", {}) + permission = qqtools.get("tool_permission", {}) + allow_users = [str(user) for user in permission.get("tool_allow_users", [])] + checks.append( + ok("qqtools.browser", f"browser enabled for {OWNER_QQ}") + if browser.get("browser") is True + and OWNER_QQ in allow_users + and "browser_*" in permission.get("admin_only_tools", []) + else fail( + "qqtools.browser", + "QQTools browser must be enabled and restricted to the owner allow-list", + ) + ) + + angel = load_json(ROOT / "data/config/astrbot_plugin_angel_heart_config.json") + angel_access = angel.get("access_control", {}) + angel_wake = angel.get("wake_interaction", {}) + checks.append( + ok("angel_heart.passive_gate", "empty whitelist blocks passive takeover") + if angel_access.get("whitelist_enabled") is True + and angel_access.get("chat_ids") == [] + and angel_access.get("group_chat_enhancement") is False + and angel_wake.get("force_reply_when_summoned") is False + and angel_wake.get("reply_even_not_questioned") is False + else fail("angel_heart.passive_gate", "AngelHeart is not fully gated") + ) + + private = load_json( + ROOT / "data/config/astrbot_plugin_private_companion_config.json" + ) + checks.append( + ok("private_companion.mode", "proactive-only auxiliary mode") + if private.get("enabled") is True + and (private.get("basic_config") or {}).get("enable_proactive_only_mode") + is True + and (private.get("group_observation_config") or {}).get( + "enable_group_companion" + ) + is False + and (private.get("humanized_state_config") or {}).get("inject_passive_states") + is False + else fail( + "private_companion.mode", + "Private Companion should be proactive-only and non-passive", + ) + ) + + wakepro = load_json(ROOT / "data/config/astrbot_plugin_wakepro_config.json") + pipeline = wakepro.get("pipeline", {}) + checks.append( + ok("wakepro.non_blocking", "mention-only pipeline") + if pipeline.get("steps") == ["mention(\u63d0\u53ca\u5524\u9192)"] + and pipeline.get("blacklist_steps") == [] + else fail( + "wakepro.non_blocking", "WakePro should not run blocking/wake/silence steps" + ) + ) + + meme = load_json(ROOT / "data/config/meme_manager_config.json") + checks.append( + ok("meme_manager.frequency", "low-frequency stickers") + if meme.get("emotion_llm_provider_id") == "deepseek/deepseek-v4-pro" + and int(meme.get("max_emotions_per_message", 0)) <= 1 + and int(meme.get("emotions_probability", 100)) <= 35 + else warn("meme_manager.frequency", "sticker frequency may be too high") + ) + + self_learning = load_json( + ROOT / "data/config/astrbot_plugin_self_learning_config.json" + ) + basic = self_learning.get("Self_Learning_Basic", {}) + ml = self_learning.get("Machine_Learning_Settings", {}) + affection = self_learning.get("Affection_System_Settings", {}) + mood = self_learning.get("Mood_System_Settings", {}) + social = self_learning.get("Social_Context_Settings", {}) + maibot = self_learning.get("MaiBot_Enhancement", {}) + integration = self_learning.get("Integration_Settings", {}) + runtime_internal = self_learning.get("Runtime_Internal_Settings", {}) + expected_on_flags = { + "enable_message_capture": basic.get("enable_message_capture"), + "enable_auto_learning": basic.get("enable_auto_learning"), + "enable_affection_system": affection.get("enable_affection_system"), + "enable_daily_mood": mood.get("enable_daily_mood"), + "enable_social_context_injection": social.get( + "enable_social_context_injection" + ), + "include_affection_info": social.get("include_affection_info"), + "enable_expression_patterns": maibot.get("enable_expression_patterns"), + "enable_llm_hooks": runtime_internal.get("enable_llm_hooks"), + } + off_slow_flags = { + "enable_jargon_learning": basic.get("enable_jargon_learning"), + "enable_style_learning": basic.get("enable_style_learning"), + "enable_realtime_learning": basic.get("enable_realtime_learning"), + "enable_ml_analysis": ml.get("enable_ml_analysis"), + "enable_memory_graph": maibot.get("enable_memory_graph"), + "enable_knowledge_graph": maibot.get("enable_knowledge_graph"), + "delegate_memory_to_livingmemory": integration.get( + "delegate_memory_to_livingmemory" + ), + } + disabled_expected = [ + key for key, value in expected_on_flags.items() if value is not True + ] + enabled_slow_flags = [key for key, value in off_slow_flags.items() if value is True] + hook_timeout = float(runtime_internal.get("llm_hook_context_timeout", 999) or 999) + checks.append( + ok( + "self_learning.balanced", + "affection/mood/capture enabled; slow paths disabled", + ) + if not disabled_expected and not enabled_slow_flags and hook_timeout <= 1.5 + else fail( + "self_learning.balanced", + f"disabled expected flags={disabled_expected}; enabled slow flags={enabled_slow_flags}; hook_timeout={hook_timeout}", + ) + ) + + livingmemory = load_json( + ROOT / "data/config/astrbot_plugin_livingmemory_config.json" + ) + embedding_provider = (livingmemory.get("provider_settings") or {}).get( + "embedding_provider_id" + ) + checks.append( + ok( + "livingmemory.disabled_or_ready", + "plugin disabled and no embedding wait should run", + ) + if "data.plugins.astrbot_plugin_livingmemory.main" in disabled_plugins + else fail( + "livingmemory.disabled_or_ready", + f"plugin is active; embedding provider={embedding_provider!r}", + ) + ) + return checks + + +def check_browser_environment(smoke: bool = False) -> list[Check]: + checks: list[Check] = [] + qqtools = load_json(ROOT / "data/config/astrbot_plugin_qq_tools_config.json") + browser_config = qqtools.get("browser_config", {}) + + checks.append( + ok("browser.config", "QQTools browser is enabled") + if browser_config.get("browser") is True + and browser_config.get("auto_install_browser_deps") is False + else fail( + "browser.config", + "QQTools browser must be enabled and auto_install_browser_deps should remain false", + ) + ) + + mark_script = ROOT / "data/plugins/astrbot_plugin_qq_tools/mark_script.js" + checks.append( + ok("browser.mark_script", str(mark_script)) + if mark_script.is_file() + else fail("browser.mark_script", f"missing {mark_script}") + ) + + playwright_spec = importlib.util.find_spec("playwright") + checks.append( + ok("browser.playwright", "playwright is importable") + if playwright_spec is not None + else fail( + "browser.playwright", + "playwright is not importable in the current Python environment", + ) + ) + + browser_path = find_system_browser_executable() + checks.append( + ok("browser.executable", str(browser_path)) + if browser_path + else fail("browser.executable", "Chrome or Edge executable was not found") + ) + + proxy = browser_proxy() + checks.append( + ok("browser.proxy", proxy) + if proxy + else warn("browser.proxy", "no proxy environment is set for browser tools") + ) + + if smoke: + if playwright_spec is None: + checks.append( + fail("browser.smoke", "skipped because playwright is not importable") + ) + else: + try: + title = asyncio.run(run_browser_smoke(browser_path, proxy)) + checks.append( + ok( + "browser.smoke", + f"headless browser launched successfully; title={title!r}", + ) + ) + except Exception as e: + checks.append( + fail("browser.smoke", f"headless browser launch failed: {e}") + ) + + return checks + + +def check_runtime_ports() -> list[Check]: + data = load_json(ROOT / "data/cmd_config.json") + checks: list[Check] = [] + any_runtime_port_open = False + runtime_owner_pids: set[str] = set() + + dashboard = data.get("dashboard", {}) + dashboard_port = int(dashboard.get("port", 6185) or 6185) + dashboard_open = is_port_open("127.0.0.1", dashboard_port) + any_runtime_port_open = any_runtime_port_open or dashboard_open + if dashboard_open: + owner_pid = get_listening_port_owner_pid(dashboard_port) + if owner_pid: + runtime_owner_pids.add(owner_pid) + checks.append( + ok( + "runtime.dashboard_port", + f"127.0.0.1:{dashboard_port} is listening{get_port_owner_label(dashboard_port)}", + ) + if dashboard_open + else warn( + "runtime.dashboard_port", + f"127.0.0.1:{dashboard_port} is not listening; AstrBot may be stopped", + ) + ) + + for platform in data.get("platform", []) or []: + if platform.get("type") != "aiocqhttp" or platform.get("enable") is not True: + continue + port = int(platform.get("ws_reverse_port", 6199) or 6199) + ws_open = is_port_open("127.0.0.1", port) + any_runtime_port_open = any_runtime_port_open or ws_open + if ws_open: + owner_pid = get_listening_port_owner_pid(port) + if owner_pid: + runtime_owner_pids.add(owner_pid) + checks.append( + ok( + "runtime.onebot_ws_port", + f"127.0.0.1:{port} is listening for NapCat reverse WebSocket{get_port_owner_label(port)}", + ) + if ws_open + else warn( + "runtime.onebot_ws_port", + f"127.0.0.1:{port} is not listening; NapCat cannot connect until AstrBot starts", + ) + ) + + log_path = resolve_data_path(data.get("log_file_path"), "logs/astrbot.log") + if any_runtime_port_open and data.get("log_file_enable") is True: + if log_path.exists(): + age_seconds = datetime.now().timestamp() - log_path.stat().st_mtime + checks.append( + ok( + "runtime.file_log", + f"{log_path} updated {int(age_seconds)} seconds ago", + ) + if age_seconds <= 3600 + else warn( + "runtime.file_log", + f"{log_path} exists but has not updated in {int(age_seconds // 60)} minutes", + ) + ) + else: + checks.append( + warn( + "runtime.file_log", + f"{log_path} does not exist yet; restart AstrBot to load file logging config", + ) + ) + + watched_files = [ + ROOT / "data/plugins/astrbot_plugin_qq_tools/main.py", + ROOT / "data/plugins/astrbot_plugin_qq_tools/tools/browser.py", + ROOT / "data/plugins/spectrecore/utils/llm_utils.py", + ] + newest_file = max( + (path for path in watched_files if path.exists()), + key=lambda path: path.stat().st_mtime, + default=None, + ) + owner_starts = [ + ts + for ts in (get_process_start_timestamp(pid) for pid in runtime_owner_pids) + if ts is not None + ] + if newest_file and owner_starts: + oldest_owner_start = min(owner_starts) + if newest_file.stat().st_mtime > oldest_owner_start + 2: + changed_at = datetime.fromtimestamp(newest_file.stat().st_mtime).strftime( + "%Y-%m-%d %H:%M:%S" + ) + started_at = datetime.fromtimestamp(oldest_owner_start).strftime( + "%Y-%m-%d %H:%M:%S" + ) + checks.append( + warn( + "runtime.restart_needed", + f"{newest_file} changed at {changed_at}, after runtime PID start {started_at}; restart AstrBot to load it", + ) + ) + else: + checks.append( + ok( + "runtime.restart_needed", + "running process is newer than watched plugin files", + ) + ) + elif any_runtime_port_open: + checks.append( + warn( + "runtime.restart_needed", + "could not compare process start time with watched plugin files", + ) + ) + return checks + + +def check_code_guards() -> list[Check]: + checks: list[Check] = [] + qqtools_main = (ROOT / "data/plugins/astrbot_plugin_qq_tools/main.py").read_text( + encoding="utf-8-sig" + ) + qqtools_browser = ( + ROOT / "data/plugins/astrbot_plugin_qq_tools/tools/browser.py" + ).read_text(encoding="utf-8-sig") + checks.append( + ok( + "code.qqtools_browser_search", + "browser_search is implemented and registered", + ) + if "class BrowserSearchTool" in qqtools_browser + and "BrowserSearchTool" in qqtools_main + and '"browser_search"' in qqtools_main + else fail( + "code.qqtools_browser_search", + "browser_search tool is missing or not registered", + ) + ) + + spectre_main = (ROOT / "data/plugins/spectrecore/main.py").read_text( + encoding="utf-8-sig" + ) + checks.append( + ok("code.spectrecore_command_skip", "command/plugin events are skipped") + if "_is_command_event" in spectre_main and "CommandFilter" in spectre_main + else fail( + "code.spectrecore_command_skip", "SpectreCore command skip guard is missing" + ) + ) + + spectre_llm = (ROOT / "data/plugins/spectrecore/utils/llm_utils.py").read_text( + encoding="utf-8-sig" + ) + checks.append( + ok("code.spectrecore_browser_prompt", "browser tools are preferred over Tavily") + if "web_search_tavily" in spectre_llm + and "browser_search" in spectre_llm + and "browser_open" in spectre_llm + else fail( + "code.spectrecore_browser_prompt", + "SpectreCore browser-search prompt guard is missing", + ) + ) + + angel_main = (ROOT / "data/plugins/astrbot_plugin_angel_heart/main.py").read_text( + encoding="utf-8-sig" + ) + whitelist_pos = angel_main.find("whitelist_enabled") + private_pos = angel_main.find("检测到私聊唤醒消息") + checks.append( + ok( + "code.angel_heart_whitelist_order", + "whitelist gate runs before private wake branch", + ) + if whitelist_pos >= 0 and private_pos >= 0 and whitelist_pos < private_pos + else fail( + "code.angel_heart_whitelist_order", + "AngelHeart whitelist gate may still be bypassed", + ) + ) + return checks + + +def iter_recent_logs(minutes: int) -> list[Path]: + logs_dir = ROOT / "data/logs" + if not logs_dir.exists(): + return [] + cutoff = datetime.now().timestamp() - minutes * 60 + return [path for path in logs_dir.glob("*.log") if path.stat().st_mtime >= cutoff] + + +def check_logs(minutes: int) -> list[Check]: + paths = iter_recent_logs(minutes) + if not paths: + return [ + warn("logs.recent", f"no .log files updated in the last {minutes} minutes") + ] + + fail_patterns = { + "RESOURCE_EXHAUSTED": "Gemini/quota exhaustion appeared", + "web_search_tavily": "built-in Tavily search was used", + "Tavily API key is not configured": "Tavily missing-key failure appeared", + "等待插件初始化超时": "a plugin initialization timeout appeared", + "等待 Provider 就绪": "a plugin is waiting for a provider", + } + text = "\n".join( + path.read_text(encoding="utf-8", errors="replace")[-200_000:] for path in paths + ) + checks: list[Check] = [ + ok("logs.recent", f"scanned {len(paths)} recent log file(s)") + ] + for pattern, detail in fail_patterns.items(): + checks.append( + fail(f"logs.bad_pattern.{pattern}", detail) + if pattern in text + else ok(f"logs.bad_pattern.{pattern}", "not found") + ) + return checks + + +def parse_log_line_timestamp(line: str) -> float | None: + if len(line) < 25 or not line.startswith("["): + return None + raw = line[1:24] + try: + return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S.%f").timestamp() + except ValueError: + return None + + +def filter_log_text_since(text: str, since_ts: float | None) -> str: + if since_ts is None: + return text + + kept: list[str] = [] + include_current = False + for line in text.splitlines(): + line_ts = parse_log_line_timestamp(line) + if line_ts is not None: + include_current = line_ts >= since_ts + if include_current: + kept.append(line) + return "\n".join(kept) + + +def read_recent_log_text( + minutes: int, since_ts: float | None = None +) -> tuple[list[Path], str]: + paths = iter_recent_logs(minutes) + text = "\n".join( + path.read_text(encoding="utf-8", errors="replace")[-200_000:] for path in paths + ) + text = filter_log_text_since(text, since_ts) + return paths, text + + +def check_startup_signals(minutes: int) -> list[Check]: + runtime_starts = get_runtime_owner_start_timestamps() + since_ts = min(runtime_starts) if runtime_starts else None + paths, text = read_recent_log_text(minutes, since_ts=since_ts) + if not paths: + return [ + warn("startup.logs", f"no .log files updated in the last {minutes} minutes") + ] + + scope = "current runtime" if since_ts is not None else f"last {minutes} minutes" + checks: list[Check] = [ + ok("startup.logs", f"scanned {len(paths)} recent log file(s), scope={scope}") + ] + expected_signals = { + "startup.onebot_connected": "aiocqhttp(OneBot v11) 适配器已连接", + "startup.qqtools_browser_registered": "Browser tools registered", + "startup.qqtools_browser_search_registered": "Browser tools registered (browser_search", + "startup.bilibili_disabled": "Plugin astrbot_plugin_bilibili is disabled.", + "startup.self_learning_affection": "好感度管理服务启动成功", + } + for name, pattern in expected_signals.items(): + checks.append( + ok(name, "found") + if pattern in text + else warn(name, f"not found in recent logs: {pattern}") + ) + + disabled_plugins = set(load_preference("inactivated_plugins", [])) + livingmemory_disabled = ( + "data.plugins.astrbot_plugin_livingmemory.main" in disabled_plugins + ) + checks.append( + ok("startup.livingmemory_disabled", "disabled in preferences") + if livingmemory_disabled + else warn( + "startup.livingmemory_disabled", + "LivingMemory is not disabled in preferences", + ) + ) + + bad_patterns = { + "startup.bad.plugin_timeout": "等待插件初始化超时", + "startup.bad.provider_wait": "等待 Provider 就绪", + "startup.bad.tavily": "Tavily API key is not configured", + "startup.bad.gemini_429": "RESOURCE_EXHAUSTED", + } + for name, pattern in bad_patterns.items(): + checks.append( + fail(name, f"found bad pattern: {pattern}") + if pattern in text + else ok(name, "not found") + ) + return checks + + +def check_search_signals(minutes: int) -> list[Check]: + runtime_starts = get_runtime_owner_start_timestamps() + since_ts = min(runtime_starts) if runtime_starts else None + paths, text = read_recent_log_text(minutes, since_ts=since_ts) + if not paths: + return [ + warn("search.logs", f"no .log files updated in the last {minutes} minutes") + ] + + scope = "current runtime" if since_ts is not None else f"last {minutes} minutes" + checks: list[Check] = [ + ok("search.logs", f"scanned {len(paths)} recent log file(s), scope={scope}") + ] + browser_search_usage_lines = [ + line + for line in text.splitlines() + if "browser_search" in line and "Browser tools registered" not in line + ] + checks.append( + ok( + "search.browser_search_used", + "browser_search tool usage appeared in recent logs", + ) + if browser_search_usage_lines + else warn( + "search.browser_search_used", + "browser_search tool usage not found yet; send a QQ search/news query after restart", + ) + ) + bad_patterns = { + "search.bad.tavily_tool": "web_search_tavily", + "search.bad.tavily_key": "Tavily API key is not configured", + "search.bad.gemini_quota": "RESOURCE_EXHAUSTED", + } + for name, pattern in bad_patterns.items(): + checks.append( + fail(name, f"found bad search-path pattern: {pattern}") + if pattern in text + else ok(name, "not found") + ) + return checks + + +def print_checks(checks: list[Check]) -> None: + width = max(len(check.name) for check in checks) if checks else 0 + for check in checks: + print(f"{check.status:<4} {check.name:<{width}} {check.detail}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--skip-logs", action="store_true", help="Do not inspect data/logs." + ) + parser.add_argument( + "--runtime", + action="store_true", + help="Also check local listening ports for a running AstrBot.", + ) + parser.add_argument( + "--browser", + action="store_true", + help="Check QQTools browser search environment.", + ) + parser.add_argument( + "--browser-smoke", + action="store_true", + help="Launch a headless browser against about:blank.", + ) + parser.add_argument( + "--startup-signals", + action="store_true", + help="Inspect recent logs for post-restart health signals.", + ) + parser.add_argument( + "--search-signals", + action="store_true", + help="Inspect recent logs for browser_search usage after a QQ search test.", + ) + parser.add_argument("--log-minutes", type=int, default=60) + parser.add_argument("--fail-on-warn", action="store_true") + args = parser.parse_args() + + checks: list[Check] = [] + checks.extend(check_core()) + checks.extend(check_plugin_configs()) + checks.extend(check_code_guards()) + if args.browser or args.browser_smoke: + checks.extend(check_browser_environment(smoke=args.browser_smoke)) + if args.runtime: + checks.extend(check_runtime_ports()) + if args.startup_signals: + checks.extend(check_startup_signals(args.log_minutes)) + if args.search_signals: + checks.extend(check_search_signals(args.log_minutes)) + if not args.skip_logs: + checks.extend(check_logs(args.log_minutes)) + + print_checks(checks) + + if any(check.status == "FAIL" for check in checks): + return 1 + if args.fail_on_warn and any(check.status == "WARN" for check in checks): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ensure_runtime_config.py b/scripts/ensure_runtime_config.py new file mode 100644 index 0000000000..85c7fe1641 --- /dev/null +++ b/scripts/ensure_runtime_config.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +import argparse +import copy +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +OWNER_QQ = "2831304142" +DEFAULT_PROVIDER = "google_gemini/gemini-2.5-flash" +FALLBACK_PROVIDER = "deepseek/deepseek-v4-pro" +PERSONA_ID = "catgirl" +DB_PATH = ROOT / "data/data_v4.db" +DISABLED_PLUGIN_MODULES = [ + "data.plugins.astrbot_plugin_livingmemory.main", + "data.plugins.astrbot_plugin_bilibili.main", +] +DISABLED_API_SEARCH_TOOLS = [ + "web_search_tavily", + "tavily_extract_web_page", + "web_search_bocha", + "web_search_brave", + "web_search_firecrawl", + "firecrawl_extract_web_page", + "web_search_baidu", + "web_search_exa", + "exa_get_contents", +] + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def save_json(path: Path, data: dict[str, Any]) -> None: + path.write_text( + json.dumps(data, ensure_ascii=False, indent=4) + "\n", encoding="utf-8" + ) + + +def ensure_list_item(values: list[Any], item: Any) -> list[Any]: + if item not in values: + values.append(item) + return values + + +def set_many(target: dict[str, Any], updates: dict[str, Any]) -> None: + for key, value in updates.items(): + target[key] = value + + +def ensure_core_config(data: dict[str, Any]) -> None: + provider_settings = data.setdefault("provider_settings", {}) + set_many( + provider_settings, + { + "default_provider_id": DEFAULT_PROVIDER, + "fallback_chat_models": [FALLBACK_PROVIDER], + "default_personality": PERSONA_ID, + "web_search": False, + "websearch_provider": "tavily", + "web_search_link": False, + "request_max_retries": 2, + }, + ) + data["log_file_enable"] = True + data["trace_log_enable"] = False + + ltm = data.setdefault("provider_ltm_settings", {}) + ltm["active_reply"] = { + "enable": False, + "method": "possibility_reply", + "possibility_reply": 0.1, + "whitelist": [], + } + ltm["image_caption"] = False + provider_settings["default_image_caption_provider_id"] = FALLBACK_PROVIDER + + for source in data.get("provider_sources", []) or []: + source_id = str(source.get("id", "")) + provider_name = str(source.get("provider", "")) + if source_id.startswith("google_gemini") or provider_name == "google": + source["enable"] = True + source["proxy"] = "http://127.0.0.1:7897" + source["gm_resp_image_modal"] = False + elif source_id == "deepseek" or provider_name == "deepseek": + source["enable"] = True + + for provider in data.get("provider", []) or []: + provider_id = str(provider.get("id", "")) + if provider_id.startswith("google_gemini/"): + provider["enable"] = provider_id == DEFAULT_PROVIDER + elif provider_id in {DEFAULT_PROVIDER, FALLBACK_PROVIDER}: + provider["enable"] = True + + +def ensure_spectrecore(data: dict[str, Any]) -> None: + data["use_func_tool"] = True + data["read_air"] = False + data["enable_all_groups"] = True + data["enabled_private"] = True + + freq = data.setdefault("model_frequency", {}) + freq["method"] = "\u6982\u7387\u56de\u590d" + freq.setdefault("probability", {})["probability"] = 0.3 + keywords = list(freq.get("keywords", [])) + for keyword in [ + "\u5c0f\u52a9\u624b", + "bot", + "\u673a\u5668\u4eba", + "\u641c\u7d22", + "\u8054\u7f51", + "\u67e5\u4e00\u4e0b", + "\u67e5\u4e0b", + "\u6700\u65b0", + "\u7f51\u9875", + "\u5b98\u7f51", + "\u8d44\u6599", + "\u4ef7\u683c", + "\u7248\u672c", + ]: + ensure_list_item(keywords, keyword) + freq["keywords"] = keywords + + +def ensure_qqtools(data: dict[str, Any]) -> None: + browser = data.setdefault("browser_config", {}) + browser["browser"] = True + browser["auto_install_browser_deps"] = False + + permission = data.setdefault("tool_permission", {}) + admin_only_tools = list(permission.get("admin_only_tools", [])) + ensure_list_item(admin_only_tools, "browser_*") + permission["admin_only_tools"] = admin_only_tools + + allow_users = {str(user) for user in permission.get("tool_allow_users", [])} + allow_users.add(OWNER_QQ) + permission["tool_allow_users"] = sorted(allow_users) + permission["llm_ignore_permission_check"] = False + permission["allow_group_admin"] = False + + +def ensure_angel_heart(data: dict[str, Any]) -> None: + access = data.setdefault("access_control", {}) + set_many( + access, + { + "whitelist_enabled": True, + "chat_ids": [], + "group_chat_enhancement": False, + "takeover_private_chat_context": False, + }, + ) + wake = data.setdefault("wake_interaction", {}) + set_many( + wake, + { + "analysis_on_mention_only": True, + "force_reply_when_summoned": False, + "reply_even_not_questioned": False, + }, + ) + + +def ensure_self_evolution(data: dict[str, Any]) -> None: + data.setdefault("base", {})["persona_name"] = PERSONA_ID + for group, updates in { + "memory_summary": { + "enable_kb_memory_recall": False, + "memory_query_fallback_enabled": False, + }, + "profile": { + "enable_profile_injection": False, + "enable_profile_fact_writeback": False, + "auto_profile_enabled": False, + }, + "reflection": {"reflection_enabled": False}, + "engagement": {"interject_enabled": False}, + "affinity": { + "affinity_auto_enabled": False, + "affinity_recovery_enabled": False, + }, + "san": {"san_auto_analyze_enabled": False}, + "sticker": {"entertainment_enabled": False}, + "prompt": { + "inject_group_history": False, + "surprise_enabled": False, + "dropout_enabled": False, + "max_prompt_injection_length": 800, + }, + "poke": {"poke_reply_enabled": False}, + "sticker_reply": {"sticker_reply_enabled": False}, + }.items(): + set_many(data.setdefault(group, {}), updates) + + +def ensure_self_learning(data: dict[str, Any]) -> None: + data.setdefault("Target_Settings", {})["current_persona_name"] = PERSONA_ID + set_many( + data.setdefault("Self_Learning_Basic", {}), + { + "enable_message_capture": True, + "enable_auto_learning": True, + "enable_jargon_learning": False, + "enable_style_learning": False, + "enable_realtime_learning": False, + "enable_realtime_llm_filter": False, + }, + ) + set_many( + data.setdefault("Social_Context_Settings", {}), + { + "enable_social_context_injection": True, + "include_social_relations": True, + "include_affection_info": True, + "include_mood_info": True, + }, + ) + set_many( + data.setdefault("Persona_Evolution_Settings", {}), + { + "enable_persona_evolution": False, + "use_persona_manager_updates": False, + "auto_apply_persona_updates": False, + }, + ) + set_many( + data.setdefault("Machine_Learning_Settings", {}), + {"enable_ml_analysis": False}, + ) + set_many( + data.setdefault("Affection_System_Settings", {}), + {"enable_affection_system": True}, + ) + set_many( + data.setdefault("Mood_System_Settings", {}), + { + "enable_daily_mood": True, + "enable_startup_random_mood": True, + }, + ) + set_many( + data.setdefault("MaiBot_Enhancement", {}), + { + "enable_expression_patterns": True, + "enable_memory_graph": False, + "enable_knowledge_graph": False, + "enable_time_decay": False, + }, + ) + set_many( + data.setdefault("Integration_Settings", {}), + { + "delegate_memory_to_livingmemory": False, + "disable_local_memory_when_delegated": False, + }, + ) + runtime = data.setdefault("Runtime_Internal_Settings", {}) + runtime["enable_llm_hooks"] = True + runtime["llm_hook_context_timeout"] = 1.5 + + +def ensure_livingmemory(data: dict[str, Any]) -> None: + set_many( + data.setdefault("session_manager", {}), + { + "enable_full_group_capture": False, + "context_window_size": 12, + "max_messages_per_session": 100, + }, + ) + set_many( + data.setdefault("agent_tools", {}), + { + "enable_recall_tool": False, + "enable_memorize_tool": False, + }, + ) + data.setdefault("graph_memory", {})["enabled"] = False + data.setdefault("migration_settings", {})["create_backup"] = False + data.setdefault("backup_settings", {})["enabled"] = False + data.setdefault("forgetting_agent", {})["auto_cleanup_enabled"] = False + + +def ensure_bilibili(data: dict[str, Any]) -> None: + data["enable_parse_miniapp"] = False + data["enable_parse_BV"] = False + data["enable_ai_summary"] = False + data["interval_secs"] = max(int(data.get("interval_secs", 300) or 300), 3600) + + +def ensure_meme_manager(data: dict[str, Any]) -> None: + data["emotion_llm_provider_id"] = FALLBACK_PROVIDER + data["emotions_probability"] = 35 + data["mixed_message_probability"] = 25 + data["max_emotions_per_message"] = 1 + data["strict_max_emotions_per_message"] = True + + +def ensure_private_companion(data: dict[str, Any]) -> None: + data["enabled"] = True + basic = data.setdefault("basic_config", {}) + basic["enable_proactive_only_mode"] = True + basic["plugin_specific_persona_id"] = PERSONA_ID + basic["target_user_ids"] = [OWNER_QQ] + + group = data.setdefault("group_observation_config", {}) + set_many( + group, + { + "enable_group_companion": False, + "enable_group_context_injection": False, + "enable_group_injection_guard": True, + "enable_group_persona_denoise": True, + }, + ) + set_many( + data.setdefault("group_interject_config", {}), + { + "enable_group_interjection": False, + "group_interject_max_daily": 0, + }, + ) + set_many( + data.setdefault("group_wakeup_config", {}), + { + "enable_group_wakeup_enhancement": False, + "group_wakeup_context_words": [], + }, + ) + data.setdefault("humanized_state_config", {})["inject_passive_states"] = False + + for section, keys in { + "daily_creative_config": ["enable_daily_greetings", "enable_creative_writing"], + "bilibili_config": ["enable_bilibili_boredom_watch"], + "qzone_config": [ + "enable_qzone_life_publish", + "enable_qzone_generated_image_publish", + "enable_qzone_comment_inbox", + ], + "web_exploration_config": [ + "enable_web_exploration", + "enable_web_exploration_boredom_search", + "enable_news_daily_hot_read", + ], + "news_config": [ + "enable_news_integration", + "enable_news_boredom_read", + "enable_ai_daily_watch", + ], + }.items(): + target = data.setdefault(section, {}) + for key in keys: + target[key] = False + + +def ensure_wakepro(data: dict[str, Any]) -> None: + pipeline = data.setdefault("pipeline", {}) + pipeline["steps"] = ["mention(\u63d0\u53ca\u5524\u9192)"] + pipeline["whitelist_steps"] = [] + pipeline["blacklist_steps"] = [] + + mention = data.setdefault("mention", {}) + mention["disable_reply_wake"] = False + mention["names"] = ["\u5c0f\u52a9\u624b", "\u673a\u5668\u4eba", "bot"] + + command = data.setdefault("command", {}) + command["block_builtin"] = False + command["block_prefix_cmd"] = False + command["block_prefix_llm"] = False + + +CONFIG_TASKS = [ + ("data/cmd_config.json", ensure_core_config), + ( + "data/config/abconf_ed78df44-c118-4a3e-b3f6-0f1b5a3bdb59.json", + ensure_core_config, + ), + ("data/config/spectrecore_config.json", ensure_spectrecore), + ("data/config/astrbot_plugin_qq_tools_config.json", ensure_qqtools), + ("data/config/astrbot_plugin_angel_heart_config.json", ensure_angel_heart), + ("data/config/astrbot_plugin_self_evolution_config.json", ensure_self_evolution), + ("data/config/astrbot_plugin_self_learning_config.json", ensure_self_learning), + ("data/config/astrbot_plugin_livingmemory_config.json", ensure_livingmemory), + ("data/config/astrbot_plugin_bilibili_config.json", ensure_bilibili), + ("data/config/meme_manager_config.json", ensure_meme_manager), + ( + "data/config/astrbot_plugin_private_companion_config.json", + ensure_private_companion, + ), + ("data/config/astrbot_plugin_wakepro_config.json", ensure_wakepro), +] + + +def load_preference(conn: sqlite3.Connection, key: str, default: Any) -> Any: + row = conn.execute( + "select value from preferences where scope=? and scope_id=? and key=?", + ("global", "global", key), + ).fetchone() + if not row: + return default + try: + payload = json.loads(row[0]) + except json.JSONDecodeError: + return default + return payload.get("val", default) + + +def save_preference(conn: sqlite3.Connection, key: str, value: Any) -> None: + now = datetime.now().isoformat(sep=" ", timespec="seconds") + payload = json.dumps({"val": value}, ensure_ascii=False) + row = conn.execute( + "select id from preferences where scope=? and scope_id=? and key=?", + ("global", "global", key), + ).fetchone() + if row: + conn.execute( + "update preferences set updated_at=?, value=? where id=?", + (now, payload, row[0]), + ) + else: + conn.execute( + 'insert into preferences (created_at, updated_at, scope, scope_id, "key", value) values (?, ?, ?, ?, ?, ?)', + (now, now, "global", "global", key, payload), + ) + + +def ensure_shared_preferences(check: bool) -> list[str]: + if not DB_PATH.exists(): + return [] + + changed: list[str] = [] + with sqlite3.connect(DB_PATH) as conn: + disabled = list(load_preference(conn, "inactivated_plugins", [])) + before_disabled = list(disabled) + for module_path in DISABLED_PLUGIN_MODULES: + ensure_list_item(disabled, module_path) + if disabled != before_disabled: + changed.append("preferences:inactivated_plugins") + if not check: + save_preference(conn, "inactivated_plugins", disabled) + conn.commit() + disabled_tools = list(load_preference(conn, "inactivated_llm_tools", [])) + before_disabled_tools = list(disabled_tools) + for tool_name in DISABLED_API_SEARCH_TOOLS: + ensure_list_item(disabled_tools, tool_name) + if disabled_tools != before_disabled_tools: + changed.append("preferences:inactivated_llm_tools") + if not check: + save_preference(conn, "inactivated_llm_tools", disabled_tools) + conn.commit() + return changed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", action="store_true", help="Only validate drift; do not write files." + ) + args = parser.parse_args() + + changed: list[Path] = [] + missing: list[Path] = [] + + for relative_path, ensure_func in CONFIG_TASKS: + path = ROOT / relative_path + if not path.exists(): + missing.append(path) + continue + + data = load_json(path) + before = copy.deepcopy(data) + ensure_func(data) + if data != before: + changed.append(path) + if not args.check: + save_json(path, data) + + for path in missing: + print(f"MISSING {path}") + + if changed: + action = "DRIFT" if args.check else "FIXED" + for path in changed: + print(f"{action} {path}") + else: + print("Runtime config is already aligned.") + + preference_changes = ensure_shared_preferences(args.check) + if preference_changes: + action = "DRIFT" if args.check else "FIXED" + for item in preference_changes: + print(f"{action} {item}") + + if missing or (args.check and (changed or preference_changes)): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/start_with_logs.ps1 b/scripts/start_with_logs.ps1 new file mode 100644 index 0000000000..cb378d8f9e --- /dev/null +++ b/scripts/start_with_logs.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$MainScript = Join-Path $Root "start_astrbot.ps1" + +Write-Host "scripts\start_with_logs.ps1 is a compatibility wrapper." +Write-Host "Forwarding to start_astrbot.ps1 so config guard, health checks, and port protection are applied." + +& powershell -NoProfile -ExecutionPolicy Bypass -File $MainScript @args +exit $LASTEXITCODE diff --git a/start_astrbot.bat b/start_astrbot.bat new file mode 100644 index 0000000000..cb3c42a297 --- /dev/null +++ b/start_astrbot.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +cd /d "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File ".\start_astrbot.ps1" %* +set "EXIT_CODE=%ERRORLEVEL%" + +if "%~1"=="" ( + echo. + echo AstrBot has stopped. Press any key to close this window. + pause >nul +) + +exit /b %EXIT_CODE% diff --git a/start_astrbot.ps1 b/start_astrbot.ps1 new file mode 100644 index 0000000000..ec31935f37 --- /dev/null +++ b/start_astrbot.ps1 @@ -0,0 +1,475 @@ +param( + [switch]$CheckOnly, + [switch]$FullCheck, + [switch]$RuntimeCheck, + [switch]$BrowserCheck, + [switch]$BrowserSmoke, + [switch]$StartupSignals, + [switch]$SearchSignals, + [switch]$PostRestartCheck, + [switch]$PostRestartSmokeCheck, + [switch]$FailOnWarn, + [switch]$StopExisting +) + +$ErrorActionPreference = "Stop" + +chcp 65001 | Out-Null +$env:PYTHONIOENCODING = "utf-8" +$env:PYTHONUTF8 = "1" +if (-not $env:HTTP_PROXY) { + $env:HTTP_PROXY = "http://127.0.0.1:7897" +} +if (-not $env:HTTPS_PROXY) { + $env:HTTPS_PROXY = "http://127.0.0.1:7897" +} +if (-not $env:QQTOOLS_BROWSER_PROXY) { + $env:QQTOOLS_BROWSER_PROXY = $env:HTTPS_PROXY +} + +$Root = $PSScriptRoot +$UvPath = "C:\Users\mai\.local\bin\uv.exe" +$PythonPath = Join-Path $Root ".venv\Scripts\python.exe" + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')" + } +} + +function Test-TcpPortOpen { + param( + [Parameter(Mandatory = $true)] + [string]$HostName, + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $Client = [System.Net.Sockets.TcpClient]::new() + try { + $Connect = $Client.BeginConnect($HostName, $Port, $null, $null) + if (-not $Connect.AsyncWaitHandle.WaitOne(750)) { + return $false + } + $Client.EndConnect($Connect) + return $true + } + catch { + return $false + } + finally { + $Client.Close() + } +} + +function Get-ListeningPortOwner { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $Connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $Connection) { + return $null + } + + $Process = Get-Process -Id $Connection.OwningProcess -ErrorAction SilentlyContinue + $ProcessName = "" + if ($Process) { + $ProcessName = $Process.ProcessName + } + + [pscustomobject]@{ + Port = $Port + ProcessId = [int]$Connection.OwningProcess + ProcessName = $ProcessName + } +} + +function Get-PortOwnerLabel { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $Owner = Get-ListeningPortOwner -Port $Port + if (-not $Owner) { + return "" + } + if ($Owner.ProcessName) { + return " (PID $($Owner.ProcessId), $($Owner.ProcessName))" + } + return " (PID $($Owner.ProcessId))" +} + +function Get-ProcessChain { + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + $Chain = @() + $CurrentProcessId = $ProcessId + for ($Depth = 0; $Depth -lt 8; $Depth++) { + $ProcessInfo = Get-CimInstance Win32_Process -Filter "ProcessId = $CurrentProcessId" -ErrorAction SilentlyContinue + if (-not $ProcessInfo) { + break + } + $Chain += $ProcessInfo + if (-not $ProcessInfo.ParentProcessId -or $ProcessInfo.ParentProcessId -eq $CurrentProcessId) { + break + } + $CurrentProcessId = [int]$ProcessInfo.ParentProcessId + } + return $Chain +} + +function Test-IsCurrentAstrBotProcess { + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + $Chain = Get-ProcessChain -ProcessId $ProcessId + foreach ($ProcessInfo in $Chain) { + if (Test-ProcessCommandBelongsToCurrentAstrBot -ProcessInfo $ProcessInfo) { + return $true + } + } + return $false +} + +function Test-ProcessCommandBelongsToCurrentAstrBot { + param( + [Parameter(Mandatory = $true)] + [object]$ProcessInfo + ) + + $CommandLine = [string]$ProcessInfo.CommandLine + return ( + $CommandLine.IndexOf($Root, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and + $CommandLine.IndexOf("main.py", [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) +} + +function Test-IsAstrBotRuntimeCommand { + param( + [Parameter(Mandatory = $true)] + [object]$ProcessInfo + ) + + $Name = ([string]$ProcessInfo.Name).ToLowerInvariant() + $CommandLine = [string]$ProcessInfo.CommandLine + return ( + @("python.exe", "python", "uv.exe", "uv") -contains $Name -and + $CommandLine.IndexOf("main.py", [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) +} + +function Get-AstrBotRuntimeProcesses { + $AllProcesses = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $RuntimeByProcessId = @{} + + foreach ($ProcessInfo in $AllProcesses) { + if ( + (Test-IsAstrBotRuntimeCommand -ProcessInfo $ProcessInfo) -and + ([string]$ProcessInfo.CommandLine).IndexOf($Root, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 + ) { + $RuntimeByProcessId[[int]$ProcessInfo.ProcessId] = $ProcessInfo + } + } + + $Changed = $true + while ($Changed) { + $Changed = $false + foreach ($ProcessInfo in $AllProcesses) { + $ProcessId = [int]$ProcessInfo.ProcessId + if ($RuntimeByProcessId.ContainsKey($ProcessId)) { + continue + } + if (-not (Test-IsAstrBotRuntimeCommand -ProcessInfo $ProcessInfo)) { + continue + } + + $ParentProcessId = [int]$ProcessInfo.ParentProcessId + $IsParentOfRuntime = $false + foreach ($RuntimeProcess in $RuntimeByProcessId.Values) { + if ([int]$RuntimeProcess.ParentProcessId -eq $ProcessId) { + $IsParentOfRuntime = $true + break + } + } + + if ($RuntimeByProcessId.ContainsKey($ParentProcessId) -or $IsParentOfRuntime) { + $RuntimeByProcessId[$ProcessId] = $ProcessInfo + $Changed = $true + } + } + } + + return @($RuntimeByProcessId.Values | Sort-Object ProcessId) +} + +function Format-ProcessSummary { + param( + [Parameter(Mandatory = $true)] + [object]$ProcessInfo + ) + + $CommandLine = [string]$ProcessInfo.CommandLine + if ($CommandLine.Length -gt 140) { + $CommandLine = $CommandLine.Substring(0, 137) + "..." + } + return "PID $($ProcessInfo.ProcessId) ($($ProcessInfo.Name), PPID $($ProcessInfo.ParentProcessId)): $CommandLine" +} + +function Stop-ExistingAstrBotOwners { + param( + [Parameter(Mandatory = $true)] + [object[]]$Owners + ) + + $ProcessIds = Get-AstrBotStopPlan -Owners $Owners + foreach ($ProcessId in $ProcessIds) { + Write-Host "Stopping old AstrBot process PID $ProcessId ..." -ForegroundColor Yellow + Stop-Process -Id $ProcessId -Force + } + Start-Sleep -Seconds 2 +} + +function Get-AstrBotStopPlan { + param( + [Parameter(Mandatory = $true)] + [object[]]$Owners + ) + + $RuntimeProcesses = @(Get-AstrBotRuntimeProcesses) + $RuntimeProcessIds = @($RuntimeProcesses | ForEach-Object { [int]$_.ProcessId }) + + $ProcessIds = @() + foreach ($Owner in $Owners) { + if ( + ($RuntimeProcessIds -notcontains [int]$Owner.ProcessId) -and + (-not (Test-IsCurrentAstrBotProcess -ProcessId $Owner.ProcessId)) + ) { + throw "Refusing to stop PID $($Owner.ProcessId); its process chain does not clearly belong to $Root." + } + + if ($ProcessIds -notcontains $Owner.ProcessId) { + $ProcessIds += $Owner.ProcessId + } + + foreach ($ProcessInfo in (Get-ProcessChain -ProcessId $Owner.ProcessId)) { + if ( + (Test-ProcessCommandBelongsToCurrentAstrBot -ProcessInfo $ProcessInfo) -and + ($ProcessIds -notcontains [int]$ProcessInfo.ProcessId) + ) { + $ProcessIds += [int]$ProcessInfo.ProcessId + } + } + } + + foreach ($RuntimeProcessId in $RuntimeProcessIds) { + if ($ProcessIds -notcontains $RuntimeProcessId) { + $ProcessIds += $RuntimeProcessId + } + } + + return $ProcessIds +} + +function Assert-NoStaleAstrBotProcesses { + $RuntimeProcesses = @(Get-AstrBotRuntimeProcesses) + if ($RuntimeProcesses.Count -eq 0) { + return + } + + if ($StopExisting) { + $Owners = @() + foreach ($ProcessInfo in $RuntimeProcesses) { + $Owners += [pscustomobject]@{ + Port = 0 + ProcessId = [int]$ProcessInfo.ProcessId + ProcessName = [string]$ProcessInfo.Name + } + } + Stop-ExistingAstrBotOwners -Owners $Owners + Assert-NoStaleAstrBotProcesses + return + } + + Write-Host "AstrBot runtime processes already exist. They may be a running instance or a stale hung chain:" -ForegroundColor Yellow + foreach ($ProcessInfo in $RuntimeProcesses) { + Write-Host " - $(Format-ProcessSummary -ProcessInfo $ProcessInfo)" -ForegroundColor Yellow + } + Write-Host "If this is the old stuck instance, run this script with -StopExisting to stop only the matching AstrBot process chain." -ForegroundColor Yellow + Write-Host "Or run with -CheckOnly -RuntimeCheck to inspect without starting a second instance." -ForegroundColor Yellow + exit 2 +} + +function Assert-StartupPortsFree { + $ConfigPath = Join-Path $Root "data\cmd_config.json" + if (-not (Test-Path $ConfigPath)) { + return + } + + $Config = Get-Content $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + $BusyPorts = @() + $BusyOwners = @() + + if ($Config.dashboard -and $Config.dashboard.enable) { + $DashboardPort = [int]$Config.dashboard.port + if (Test-TcpPortOpen -HostName "127.0.0.1" -Port $DashboardPort) { + $BusyPorts += "dashboard 127.0.0.1:$DashboardPort$(Get-PortOwnerLabel -Port $DashboardPort)" + $Owner = Get-ListeningPortOwner -Port $DashboardPort + if ($Owner) { + $BusyOwners += $Owner + } + } + } + + foreach ($Platform in @($Config.platform)) { + if ($Platform.type -eq "aiocqhttp" -and $Platform.enable) { + $WsPort = [int]$Platform.ws_reverse_port + if (Test-TcpPortOpen -HostName "127.0.0.1" -Port $WsPort) { + $BusyPorts += "OneBot reverse WebSocket 127.0.0.1:$WsPort$(Get-PortOwnerLabel -Port $WsPort)" + $Owner = Get-ListeningPortOwner -Port $WsPort + if ($Owner) { + $BusyOwners += $Owner + } + } + } + } + + if ($BusyPorts.Count -gt 0) { + if ($StopExisting) { + if ($BusyOwners.Count -eq 0) { + throw "Ports are busy, but no owning process could be identified." + } + Stop-ExistingAstrBotOwners -Owners $BusyOwners + Assert-StartupPortsFree + return + } + + Write-Host "AstrBot already appears to be running or its ports are occupied:" -ForegroundColor Yellow + foreach ($BusyPort in $BusyPorts) { + Write-Host " - $BusyPort" -ForegroundColor Yellow + } + if ($BusyOwners.Count -gt 0) { + try { + $StopPlan = Get-AstrBotStopPlan -Owners $BusyOwners + if ($StopPlan.Count -gt 0) { + Write-Host "If you run with -StopExisting, these current AstrBot PIDs will be stopped:" -ForegroundColor Yellow + foreach ($ProcessId in $StopPlan) { + $Process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + $ProcessName = if ($Process) { $Process.ProcessName } else { "unknown" } + Write-Host " - PID $ProcessId ($ProcessName)" -ForegroundColor Yellow + } + } + } + catch { + Write-Host "Could not build a safe -StopExisting stop plan: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + Write-Host "To stop a listed PID manually, use: Stop-Process -Id " -ForegroundColor Yellow + Write-Host "Or run this script with -StopExisting to stop a matching old AstrBot process safely." -ForegroundColor Yellow + Write-Host "Stop the old process first, or run this script with -CheckOnly -RuntimeCheck to inspect it." -ForegroundColor Yellow + exit 2 + } +} + +if (-not (Test-Path (Join-Path $Root "main.py"))) { + throw "main.py was not found. Please put this script in the AstrBotCore root directory." +} + +if (-not (Test-Path $UvPath)) { + $UvCommand = Get-Command "uv.exe" -ErrorAction SilentlyContinue + if (-not $UvCommand) { + throw "uv.exe not found. Please install uv or update `$UvPath in this script." + } + $UvPath = $UvCommand.Source +} + +if (-not (Test-Path $PythonPath)) { + Write-Host ".venv python was not found. Falling back to uv run python for config guard." + $ConfigGuardCommand = $UvPath + $ConfigGuardArgs = @("run", "python", ".\scripts\ensure_runtime_config.py") + $HealthCheckCommand = $UvPath + $HealthCheckArgs = @("run", "python", ".\scripts\diagnose_runtime_health.py") +} +else { + $ConfigGuardCommand = $PythonPath + $ConfigGuardArgs = @(".\scripts\ensure_runtime_config.py") + $HealthCheckCommand = $PythonPath + $HealthCheckArgs = @(".\scripts\diagnose_runtime_health.py") +} + +Write-Host "AstrBot root: $Root" +Write-Host "uv path: $UvPath" +Write-Host "config guard: $ConfigGuardCommand" + +Set-Location $Root + +if ($FullCheck) { + $CheckOnly = $true + $RuntimeCheck = $true + $BrowserCheck = $true + $StartupSignals = $true +} + +if ($PostRestartCheck) { + $CheckOnly = $true + $RuntimeCheck = $true + $BrowserCheck = $true + $StartupSignals = $true + $SearchSignals = $true +} + +if ($PostRestartSmokeCheck) { + $CheckOnly = $true + $RuntimeCheck = $true + $BrowserCheck = $true + $BrowserSmoke = $true + $StartupSignals = $true + $SearchSignals = $true +} + +if ($CheckOnly) { + Invoke-CheckedCommand -FilePath $ConfigGuardCommand -Arguments ($ConfigGuardArgs + @("--check")) + $CheckArgs = $HealthCheckArgs + if ($RuntimeCheck) { + $CheckArgs = $CheckArgs + @("--runtime") + } + if ($BrowserCheck) { + $CheckArgs = $CheckArgs + @("--browser") + } + if ($BrowserSmoke) { + $CheckArgs = $CheckArgs + @("--browser-smoke") + } + if ($StartupSignals) { + $CheckArgs = $CheckArgs + @("--startup-signals") + } + if ($SearchSignals) { + $CheckArgs = $CheckArgs + @("--search-signals") + } + if ($FailOnWarn) { + $CheckArgs = $CheckArgs + @("--fail-on-warn") + } + Invoke-CheckedCommand -FilePath $HealthCheckCommand -Arguments $CheckArgs + Write-Host "Check completed. AstrBot was not started." + exit 0 +} + +Invoke-CheckedCommand -FilePath $ConfigGuardCommand -Arguments $ConfigGuardArgs +Invoke-CheckedCommand -FilePath $HealthCheckCommand -Arguments ($HealthCheckArgs + @("--skip-logs")) +Assert-NoStaleAstrBotProcesses +Assert-StartupPortsFree +Invoke-CheckedCommand -FilePath $UvPath -Arguments @("run", "main.py")