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/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), diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 210eb21005..cc5097b70a 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,125 @@ 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 + 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__ + + 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__ + ) + 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__