From 2ad10fa24c1fc9f61f6fb5baa5afa2368be23ba4 Mon Sep 17 00:00:00 2001 From: Dr1985 <140971685+Dr1985@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:00:20 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=88=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=87=E4=BB=BD=E4=B8=8E=E5=9B=9E=E6=BB=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### **Description** 本 PR 实现了 #8686 中提出的自动更新功能需求。启用后,AstrBot 将定期检查新版本,自动下载并安装更新,在应用更新前创建备份,并在更新失败时自动回滚。 ### **Features** - **定期检查更新**:可配置的 cron 调度(默认:每天凌晨 3 点) - **自动安装**:检测到新版本时,机器人可自动下载并应用更新 - **更新前备份**:在应用更新前自动创建完整的数据备份 - **自动回滚**:如果更新失败,机器人会自动从更新前的备份中恢复 - **手动回滚**:可通过控制台 (Dashboard) API 手动恢复任何备份 - **备份自动清理**:旧的自动更新备份将在可配置的保留期后自动删除(默认:14 天) - **控制台 API**:提供完整的 REST API,用于管理自动更新设置、查看历史记录、列出备份列表以及触发手动回滚 ### **Configuration** 以下设置位于 `cmd_config.json` 的 `auto_update` 键下(也可通过 WebUI 进行编辑): |**键名 (Key)**|**类型 (Type)**|**默认值 (Default)**|**描述 (Description)**| |---|---|---|---| |`enabled`|`bool`|`false`|启用自动更新| |`cron_expression`|`string`|`"0 3 * * *"`|用于检查更新的 Cron 表达式| |`auto_install`|`bool`|`false`|自动安装更新(false = 仅通知)| |`backup_before_update`|`bool`|`true`|应用更新前创建备份| |`backup_retention_days`|`int`|`14`|自动更新备份保留天数| |`consider_prerelease`|`bool`|`false`|包含 alpha/beta/rc 版本| |`timezone`|`string`|`null`|用于 Cron 调度的时区| ### **Dashboard API Endpoints** |**请求方法 (Method)**|**路径 (Path)**|**描述 (Description)**| |---|---|---| |`GET`|`/api/update/auto-update/settings`|获取当前自动更新设置| |`POST`|`/api/update/auto-update/settings/update`|更新自动更新设置| |`POST`|`/api/update/auto-update/check`|触发立即检查更新| |`GET`|`/api/update/auto-update/history`|查看自动更新事件历史记录| |`GET`|`/api/update/auto-update/backups`|列出可用的备份文件| |`POST`|`/api/update/auto-update/rollback`|手动回滚到指定备份| ### **Files Changed** - **新增**: `astrbot/core/auto_update/__init__.py` — 模块导出 - **新增**: `astrbot/core/auto_update/auto_updater.py` — `AutoUpdateManager` 类 - **修改**: `astrbot/core/config/default.py` — 添加了 `auto_update` 配置 schema - **修改**: `astrbot/core/core_lifecycle.py` — 在启动期间初始化 `AutoUpdateManager` - **修改**: `astrbot/dashboard/routes/update.py` — 添加了 6 个自动更新 API 端点 ### **How It Works** 启动时,如果 `auto_update.enabled` 为 `true`,管理器将注册以下 cron 任务: - 根据配置的调度执行的**更新检查任务** - **备份清理任务**(每天凌晨 3:07) 当检查任务触发时: 1. 调用 `AstrBotUpdator.check_update()` 请求 release API。 2. 如果没有可用更新,则记录日志并写入历史记录。 3. 如果 `auto_install` 为 `false`,则仅记录“发现可用更新”。 4. 如果 `auto_install` 为 `true`: - 通过 `AstrBotExporter.export_all()` 创建完整备份。 - 通过 `AstrBotUpdator.update()` 下载并解压更新。 - **如果成功**:重启进程。 - **如果失败**:通过 `AstrBotImporter.import_all()` 从备份中恢复。 5. 所有事件均记录在 `data/auto_update_history.json` 中。 ### **Testing** - 在配置中设置 `auto_update.enabled = true` 且 `auto_update.auto_install = false`。 - 启动 AstrBot — 验证 cron 任务已成功调度。 - 通过 `POST /api/update/auto-update/check` 触发手动检查。 - 验证历史记录是否正确显示了检查结果。 - **测试完整流程**:设置 `auto_install = true`,Mock (模拟) 一次 release 检查以返回新版本,并验证 `备份 → 安装 → 重启` 的完整顺畅流程。 ### **Related Issue** Closes #8686 --- astrbot/core/config/default.py | 41 +++++++++ astrbot/core/core_lifecycle.py | 24 ++++++ astrbot/dashboard/routes/update.py | 133 +++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b60c7f2307..83f2627ae3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -308,6 +308,47 @@ "kb_agentic_mode": False, "disable_builtin_commands": False, "disable_metrics": False, + "auto_update": { + "description": "自动更新设置", + "type": "object", + "properties": { + "enabled": { + "description": "启用自动更新", + "type": "bool", + "hint": "开启后,AstrBot 将定期检查并自动安装更新。", + }, + "cron_expression": { + "description": "检查更新的 Cron 表达式", + "type": "string", + "hint": "默认为每天凌晨 3 点检查。", + }, + "auto_install": { + "description": "自动安装更新", + "type": "bool", + "hint": "关闭时仅通知有新版本,不自动安装。", + }, + "backup_before_update": { + "description": "更新前自动备份", + "type": "bool", + "hint": "开启后,更新前会自动创建完整数据备份。", + }, + "backup_retention_days": { + "description": "备份保留天数", + "type": "int", + "hint": "自动更新创建的备份将在此天数后被自动删除。默认 14 天。", + }, + "consider_prerelease": { + "description": "包含预发布版本", + "type": "bool", + "hint": "开启后,也会检查 alpha/beta/rc 等预发布版本。", + }, + "timezone": { + "description": "时区", + "type": "string", + "hint": "用于 Cron 调度的时区,例如 Asia/Shanghai。留空使用系统时区。", + }, + }, + }, } diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index c325a2ea38..485cf8f001 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -19,6 +19,9 @@ from astrbot.api import logger, sp from astrbot.core import LogBroker, LogManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager +from astrbot.core.auto_update import AutoUpdateManager +from astrbot.core.backup.exporter import AstrBotExporter +from astrbot.core.backup.importer import AstrBotImporter from astrbot.core.config.default import VERSION from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.cron import CronJobManager @@ -58,6 +61,7 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None + self.auto_update_manager: AutoUpdateManager | None = None self.temp_dir_cleaner: TempDirCleaner | None = None self._default_chat_provider_warning_emitted = False @@ -255,6 +259,26 @@ async def initialize(self) -> None: # 初始化更新器 self.astrbot_updator = AstrBotUpdator() + # 初始化自动更新管理器 + exporter = AstrBotExporter( + main_db=self.db, + kb_manager=self.kb_manager, + ) + importer = AstrBotImporter( + main_db=self.db, + kb_manager=self.kb_manager, + ) + self.auto_update_manager = AutoUpdateManager( + core_lifecycle=self, + db=self.db, + astrbot_config=self.astrbot_config, + updator=self.astrbot_updator, + cron_manager=self.cron_manager, + exporter=exporter, + importer=importer, + ) + await self.auto_update_manager.initialize() + # 初始化事件总线 self.event_bus = EventBus( self.event_queue, diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 210eb21005..ce830cb53e 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -31,6 +31,30 @@ def __init__( "/update/dashboard": ("POST", self.update_dashboard), "/update/pip-install": ("POST", self.install_pip_package), "/update/migration": ("POST", self.do_migration), + "/update/auto-update/settings": ( + "GET", + self.get_auto_update_settings, + ), + "/update/auto-update/settings/update": ( + "POST", + self.update_auto_update_settings, + ), + "/update/auto-update/check": ( + "POST", + self.trigger_auto_update_check, + ), + "/update/auto-update/history": ( + "GET", + self.get_auto_update_history, + ), + "/update/auto-update/backups": ( + "GET", + self.get_auto_update_backups, + ), + "/update/auto-update/rollback": ( + "POST", + self.rollback_auto_update, + ), } self.astrbot_updator = astrbot_updator self.core_lifecycle = core_lifecycle @@ -352,3 +376,112 @@ async def install_pip_package(self): except Exception as e: logger.error(f"/api/update_pip: {traceback.format_exc()}") return Response().error(e.__str__()).__dict__ + + async def _get_auto_update_mgr(self): + """Get the auto-update manager, returning an error response if unavailable.""" + mgr = getattr(self.core_lifecycle, "auto_update_manager", None) + if not mgr: + return None, Response().error( + "Auto-update manager is not available." + ).__dict__ + return mgr, None + + async def get_auto_update_settings(self): + """Get current auto-update configuration.""" + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + settings = await mgr.get_settings() + return Response().ok(settings).__dict__ + except Exception as e: + logger.error( + f"/api/update/auto-update/settings GET: {traceback.format_exc()}" + ) + return Response().error(str(e)).__dict__ + + async def update_auto_update_settings(self): + """Update auto-update configuration.""" + if DEMO_MODE: + return ( + Response() + .error("You are not permitted to do this operation in demo mode") + .__dict__ + ) + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + data = await request.json + if not data: + return Response().error("Missing request body.").__dict__ + settings = await mgr.update_settings(data) + return Response().ok(settings, "Auto-update settings updated.").__dict__ + except Exception as e: + logger.error( + f"/api/update/auto-update/settings POST: {traceback.format_exc()}" + ) + return Response().error(str(e)).__dict__ + + async def trigger_auto_update_check(self): + """Trigger an immediate update check.""" + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + result = await mgr.trigger_manual_check() + return Response().ok(result, "Update check completed.").__dict__ + except Exception as e: + logger.error(f"/api/update/auto-update/check: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ + + async def get_auto_update_history(self): + """Get auto-update event history.""" + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + limit = request.args.get("limit", 20, type=int) + history = await mgr.get_history(limit=limit) + return Response().ok(history).__dict__ + except Exception as e: + logger.error(f"/api/update/auto-update/history: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ + + async def get_auto_update_backups(self): + """List available backup files for rollback.""" + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + backups = await mgr.get_backup_files() + return Response().ok(backups).__dict__ + except Exception as e: + logger.error(f"/api/update/auto-update/backups: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ + + async def rollback_auto_update(self): + """Manually rollback to a specified backup.""" + if DEMO_MODE: + return ( + Response() + .error("You are not permitted to do this operation in demo mode") + .__dict__ + ) + mgr, err = await self._get_auto_update_mgr() + if err: + return err + try: + data = await request.json + backup_path = data.get("backup_path", "") + if not backup_path: + return Response().error("Missing backup_path parameter.").__dict__ + result = await mgr.rollback_to_backup(backup_path) + if result["success"]: + return ( + Response().ok(result, "Rollback initiated. Restarting...").__dict__ + ) + return Response().error(result.get("error", "Rollback failed.")).__dict__ + except Exception as e: + logger.error(f"/api/update/auto-update/rollback: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ From 1250121cede0af723c9f5687e2ea158189656079 Mon Sep 17 00:00:00 2001 From: Ju Boxiang <140971685+Dr1985@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:07:28 +0800 Subject: [PATCH 2/3] Update astrbot/dashboard/routes/update.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- astrbot/dashboard/routes/update.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index ce830cb53e..cc5097b70a 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -473,10 +473,23 @@ async def rollback_auto_update(self): return err try: data = await request.json + if not data: + return Response().error("Missing request body.").__dict__ backup_path = data.get("backup_path", "") if not backup_path: return Response().error("Missing backup_path parameter.").__dict__ - result = await mgr.rollback_to_backup(backup_path) + + import os + from astrbot.core.utils.astrbot_path import get_astrbot_backups_path + + # 安全校验:仅提取文件名,防止路径穿越 + filename = os.path.basename(backup_path) + safe_backup_path = os.path.join(get_astrbot_backups_path(), filename) + + if not os.path.exists(safe_backup_path): + return Response().error("Backup file does not exist.").__dict__ + + result = await mgr.rollback_to_backup(safe_backup_path) if result["success"]: return ( Response().ok(result, "Rollback initiated. Restarting...").__dict__ From 7d2cdb4043fb094def13ce09f4606796920a3065 Mon Sep 17 00:00:00 2001 From: Dr1985 <140971685+Dr1985@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:15:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20apply=20forward=5Fthreshold=20to=20s?= =?UTF-8?q?end=5Fmessage=5Fto=5Fuser=20tool=20messages=20##=20=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 问题 当 LLM 通过内置工具 `send_message_to_user` 发送消息时,无论消息内容多长,都不会触发 QQ 合并转发消息。这是因为 `send_message_to_user` 工具直接调用 `Context.send_message()` 发送消息,完全绕过了事件处理管线(Pipeline)。而 `forward_threshold` 的检查逻辑位于管线中的 `ResultDecorateStage` 阶段。 **两条发送路径对比**: - **普通 LLM 回复**:经过完整管线 → `ResultDecorateStage` 检测字数 → 超阈值时包装为 `Node` → aiocqhttp 适配器调用 `send_group_forward_msg` - **`send_message_to_user`**:`Context.send_message()` → 直接调用平台适配器的 `send_by_session()` → 跳过所有管线阶段(包括 `forward_threshold`、速率限制、内容安全检查等) ### 修复方案 在 `SendMessageToUserTool.call()` 方法中,在调用 `send_message()` 之前,添加与 `ResultDecorateStage` 相同的 `forward_threshold` 检查逻辑。当目标平台为 `aiocqhttp` 且纯文本字数超过阈值时,将消息链包装为 `Node` 组件,从而触发合并转发。 ### 修改文件 | 文件 | 修改内容 | |---|---| | `astrbot/core/tools/message_tools.py` | 在 `SendMessageToUserTool.call()` 中添加 `forward_threshold` 检查(+21 行) | ### 具体修改 在 `send_message()` 调用前插入以下逻辑: ```python # Apply forward_threshold for aiocqhttp platform. # The normal reply path applies this check in ResultDecorateStage, # but tool-sent messages bypass the pipeline, so we replicate it here. # See https://github.com/AstrBotDevs/AstrBot/issues/8678 if target_session.platform_name == "aiocqhttp": cfg = context.context.context.get_config( umo=context.context.event.unified_msg_origin ) threshold = cfg.get("platform_settings", {}).get("forward_threshold", 1500) word_cnt = 0 for comp in components: if isinstance(comp, Comp.Plain): word_cnt += len(comp.text) if word_cnt > threshold: node = Comp.Node( uin=context.context.event.get_self_id(), name="AstrBot", content=[*components], ) components = [node] ``` ### 测试方式 1. 使用 aiocqhttp(OneBot V11)平台适配器 2. 将 `platform_settings.forward_threshold` 设置为较小的值(如 100) 3. 让 LLM 调用 `send_message_to_user` 工具发送超过阈值字数的纯文本内容 4. 观察消息以合并转发形式发出,而非普通消息 ### 关联 Issue Closes #8678 --- astrbot/core/tools/message_tools.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 40516d5297..049e48de9e 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -316,6 +316,27 @@ async def call( else: return f"error: invalid session: {session}" + # Apply forward_threshold for aiocqhttp platform. + # The normal reply path applies this check in ResultDecorateStage, + # but tool-sent messages bypass the pipeline, so we replicate it here. + # See https://github.com/AstrBotDevs/AstrBot/issues/8678 + if target_session.platform_name == "aiocqhttp": + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + threshold = cfg.get("platform_settings", {}).get("forward_threshold", 1500) + word_cnt = 0 + for comp in components: + if isinstance(comp, Comp.Plain): + word_cnt += len(comp.text) + if word_cnt > threshold: + node = Comp.Node( + uin=context.context.event.get_self_id(), + name="AstrBot", + content=[*components], + ) + components = [node] + await context.context.context.send_message( target_session, MessageChain(chain=components),