diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index 6a6f5f84bc..8b99bb9541 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -7,6 +7,7 @@ from .provider import ProviderCommands from .setunset import SetUnsetCommands from .sid import SIDCommand +from .update import UpdateCommands __all__ = [ "AdminCommands", @@ -16,4 +17,5 @@ "ProviderCommands", "SetUnsetCommands", "SIDCommand", + "UpdateCommands", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/update.py b/astrbot/builtin_stars/builtin_commands/commands/update.py new file mode 100644 index 0000000000..d6bb9f23b2 --- /dev/null +++ b/astrbot/builtin_stars/builtin_commands/commands/update.py @@ -0,0 +1,186 @@ +"""Update commands for AstrBot. + +Provides /update subcommands: check, now, auto, status. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from astrbot.api.event import MessageChain +from astrbot.core.config.default import VERSION + +if TYPE_CHECKING: + from astrbot.api.event import AstrMessageEvent + from astrbot.api.star import Context + + +class UpdateCommands: + """Chat commands for checking and triggering AstrBot updates.""" + + def __init__(self, context: Context) -> None: + self.context = context + self._update_task: asyncio.Task | None = None + + async def update_check(self, event: AstrMessageEvent) -> None: + """Check for new AstrBot versions.""" + await event.send(MessageChain().message("🔍 Checking for updates...")) + + auto_update_mgr = self._get_auto_update_manager() + if auto_update_mgr is None: + await event.send( + MessageChain().message("⚠️ Update manager is not available.") + ) + return + + try: + result = await auto_update_mgr.check_now() + except Exception as exc: + await event.send( + MessageChain().message(f"❌ Failed to check for updates: {exc}") + ) + return + + if result.get("error"): + await event.send( + MessageChain().message( + f"❌ Failed to check for updates: {result['error']}" + ) + ) + return + + if result["has_update"]: + msg = ( + f"🔔 A new version is available!\n" + f"Current: {result['current_version']}\n" + f"Latest: {result['latest_version']}\n" + f"Published: {result.get('published_at', 'N/A')}\n\n" + f"Use /update now to update." + ) + else: + msg = ( + f"✅ You are running the latest version.\n" + f"Current: {result['current_version']}" + ) + + await event.send(MessageChain().message(msg)) + + async def update_now( + self, event: AstrMessageEvent, version: str | None = None + ) -> None: + """Trigger an update immediately. + + Args: + version: Specific version tag to update to, or None for latest. + """ + auto_update_mgr = self._get_auto_update_manager() + if auto_update_mgr is None: + await event.send( + MessageChain().message("⚠️ Update manager is not available.") + ) + return + + await event.send( + MessageChain().message( + f"⏳ Starting update to {version or 'latest version'}...\n" + f"A backup will be created before the update.\n" + f"The bot will restart after the update is complete." + ) + ) + + try: + # 在后台触发更新,让当前消息先发出去 + # 保存任务引用,防止被 Python 垃圾回收机制提前销毁 + self._update_task = asyncio.create_task( + self._trigger_update_with_delay(auto_update_mgr, version) + ) + except Exception as exc: + await event.send( + MessageChain().message(f"❌ Failed to trigger update: {exc}") + ) + + async def update_auto(self, event: AstrMessageEvent, action: str) -> None: + """Toggle auto-update on/off. + + Args: + action: "on" or "off". + """ + action = action.strip().lower() if action else "" + if action not in ("on", "off"): + await event.send( + MessageChain().message( + "Usage: /update auto on|off\n" + " on — Enable automatic updates\n" + " off — Disable automatic updates" + ) + ) + return + + enable = action == "on" + + # 更新运行时配置 + config = self.context.get_config() + if "auto_update" not in config: + config["auto_update"] = {} + config["auto_update"]["enabled"] = enable + + await event.send( + MessageChain().message( + f"{'✅' if enable else '🛑'} Auto-update is now " + f"{'ENABLED' if enable else 'DISABLED'}.\n" + + ( + "AstrBot will automatically check for and apply updates." + if enable + else "AstrBot will NOT automatically update itself." + ) + ) + ) + + async def update_status(self, event: AstrMessageEvent) -> None: + """Show current version and auto-update status.""" + auto_update_mgr = self._get_auto_update_manager() + config = self.context.get_config() + auto_cfg = config.get("auto_update", {}) + + enabled = auto_cfg.get("enabled", False) + check_interval_h = (auto_cfg.get("check_interval", 86400)) / 3600 + retention_days = auto_cfg.get("backup_retention_days", 14) + + new_version = None + if auto_update_mgr: + new_version = auto_update_mgr.get_new_version_info() + + msg = ( + f"📋 AstrBot Update Status\n" + f"Current version: {VERSION}\n" + f"Auto-update: {'✅ Enabled' if enabled else '⛔ Disabled'}\n" + f"Check interval: {check_interval_h:.0f}h\n" + f"Backup retention: {retention_days} days\n" + ) + if new_version: + msg += f"New version available: {new_version}\n" + + msg += "\nCommands:\n" + msg += " /update check — Check for new version\n" + msg += " /update now — Update now\n" + msg += " /update auto on|off — Toggle auto-update\n" + + await event.send(MessageChain().message(msg)) + + # ---- helpers ---- + + @staticmethod + async def _trigger_update_with_delay( + auto_update_mgr, version: str | None = None + ) -> None: + """Delay then trigger update, giving the chat message time to send.""" + await asyncio.sleep(3) + await auto_update_mgr.trigger_update(version) + + def _get_auto_update_manager(self): + """Get the AutoUpdateManager from core_lifecycle via star context.""" + core_lifecycle = getattr(self.context, "core_lifecycle", None) + if core_lifecycle is None: + return None + return getattr(core_lifecycle, "auto_update_manager", None) diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 4c5ce3f8ca..a1cd7a161b 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -10,6 +10,7 @@ ProviderCommands, SetUnsetCommands, SIDCommand, + UpdateCommands, ) @@ -24,6 +25,7 @@ def __init__(self, context: star.Context) -> None: self.provider_c = ProviderCommands(self.context) self.setunset_c = SetUnsetCommands(self.context) self.sid_c = SIDCommand(self.context) + self.update_c = UpdateCommands(self.context) @filter.command("help") async def help(self, event: AstrMessageEvent) -> None: @@ -87,3 +89,42 @@ async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> N async def unset_variable(self, event: AstrMessageEvent, key: str) -> None: """Unset session variable""" await self.setunset_c.unset_variable(event, key) + + @filter.permission_type(filter.PermissionType.ADMIN) + @filter.command("update") + async def update( + self, + event: AstrMessageEvent, + subcommand: str | None = None, + arg: str | None = None, + ) -> None: + """Manage AstrBot updates. + + Subcommands: + check — Check for new versions + now [version] — Update immediately (default: latest) + auto on|off — Enable/disable automatic updates + status — Show current version and update settings + """ + sub = (subcommand or "").strip().lower() + + if sub == "check": + await self.update_c.update_check(event) + elif sub == "now": + await self.update_c.update_now(event, arg) + elif sub == "auto": + await self.update_c.update_auto(event, arg or "") + elif sub == "status" or not sub: + await self.update_c.update_status(event) + else: + from astrbot.api.event import MessageChain + + await event.send( + MessageChain().message( + "Unknown subcommand. Usage:\n" + " /update check — Check for new version\n" + " /update now [ver] — Update immediately\n" + " /update auto on|off — Toggle auto-update\n" + " /update status — Show status" + ) + ) diff --git a/astrbot/core/auto_update.py b/astrbot/core/auto_update.py new file mode 100644 index 0000000000..b4619b1c6c --- /dev/null +++ b/astrbot/core/auto_update.py @@ -0,0 +1,358 @@ +"""AstrBot 自动更新模块 + +负责: +1. 定时检查新版本并通知管理员 +2. 定时清理过期的更新前备份 +3. 支持自动更新触发 +""" + +from __future__ import annotations + +import asyncio +import os +import time +from typing import TYPE_CHECKING + +from astrbot.api import logger +from astrbot.core.config.default import VERSION +from astrbot.core.utils.astrbot_path import get_astrbot_backups_path + +if TYPE_CHECKING: + from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + from astrbot.core.updator import AstrBotUpdator + + +class AutoUpdateManager: + """自动更新管理器。 + + 在 AstrBot 启动时注册后台任务: + - 定时检查新版本 + - 定时清理过期备份 + """ + + def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None: + self.core_lifecycle = core_lifecycle + self._check_task: asyncio.Task | None = None + self._cleanup_task: asyncio.Task | None = None + self._last_check_time: float = 0.0 + self._new_version_available: str | None = None + + @property + def config(self) -> dict: + return self.core_lifecycle.astrbot_config.get("auto_update", {}) + + @property + def enabled(self) -> bool: + return self.config.get("enabled", False) + + @property + def check_interval(self) -> int: + val = self.config.get("check_interval", 86400) + try: + val = int(val) + return val if val > 0 else 86400 + except (TypeError, ValueError): + return 86400 + + @property + def backup_retention_days(self) -> int: + val = self.config.get("backup_retention_days", 14) + try: + val = int(val) + return val if val >= 0 else 14 + except (TypeError, ValueError): + return 14 + + @property + def notify_on_new_version(self) -> bool: + return self.config.get("notify_on_new_version", True) + + async def start_background_tasks(self) -> None: + """启动后台任务。""" + # 版本检查任务 + if self._check_task is None or self._check_task.done(): + self._check_task = asyncio.create_task(self._version_check_loop()) + + # 备份清理任务 + if self._cleanup_task is None or self._cleanup_task.done(): + self._cleanup_task = asyncio.create_task(self._backup_cleanup_loop()) + + logger.info("AutoUpdateManager background tasks started.") + + async def stop_background_tasks(self) -> None: + """停止后台任务。""" + for task in (self._check_task, self._cleanup_task): + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # ---- 版本检查 ---- + + async def _version_check_loop(self) -> None: + """版本检查循环。按配置的间隔定时检查。""" + # 首次启动先等 60 秒,让服务完全就绪 + await asyncio.sleep(60) + + while True: + try: + await self._do_version_check() + except asyncio.CancelledError: + return + except Exception: + logger.warning( + "版本检查失败(不影响正常使用)。", + exc_info=True, + ) + + await asyncio.sleep(self.check_interval) + + async def _do_version_check(self) -> None: + """执行一次版本检查。""" + now = time.time() + # 去重:5 分钟内不重复检查 + if now - self._last_check_time < 300: + return + self._last_check_time = now + + updator = getattr(self.core_lifecycle, "astrbot_updator", None) + if not updator: + return + + try: + result = await updator.check_update(None, None, True) + except Exception: + return + + if result is not None: + new_ver = result.version + self._new_version_available = new_ver + logger.info(f"发现新版本: {new_ver}(当前: {VERSION})") + + if self.notify_on_new_version: + await self._notify_admins_new_version(new_ver) + + # 如果开启了自动更新,触发更新 + if self.enabled: + logger.info(f"自动更新已开启,将在 30 秒后开始更新到 {new_ver}...") + await asyncio.sleep(30) + await self._trigger_auto_update(new_ver) + else: + self._new_version_available = None + + async def _notify_admins_new_version(self, new_version: str) -> None: + """通知管理员有新版本可用。 + + 通过已连接的平台向管理员发送消息。 + """ + platform_manager = getattr(self.core_lifecycle, "platform_manager", None) + if not platform_manager: + return + + admin_ids = self.core_lifecycle.astrbot_config.get("admins_id", []) + message = ( + f"🔔 AstrBot 新版本可用\n" + f"当前版本: {VERSION}\n" + f"最新版本: {new_version}\n" + f"请在 WebUI 中更新,或使用 /update now 指令。" + ) + + for platform in platform_manager.platform_insts.values(): + try: + if hasattr(platform, "send_by_session"): + # 尝试向管理员发送通知 + for admin_id in admin_ids: + try: + await platform.send_by_session( + session_id=admin_id, + message_chain=message, + ) + except Exception: + pass # 单个管理员通知失败不影响其他 + except Exception: + pass + + async def _trigger_auto_update(self, target_version: str | None = None) -> None: + """触发自动更新流程。 + + 创建备份 → 下载更新 → 应用更新 → 重启。 + 如果更新过程中发生错误,自动回滚到备份。 + """ + from astrbot.core.backup.exporter import AstrBotExporter + from astrbot.core.backup.importer import AstrBotImporter + + backup_path: str | None = None + auto_backup = self.config.get("auto_backup_before_update", True) + + # 1. 更新前备份 + if auto_backup: + try: + logger.info("正在创建更新前备份...") + kb_manager = getattr(self.core_lifecycle, "kb_manager", None) + exporter = AstrBotExporter( + main_db=self.core_lifecycle.db, + kb_manager=kb_manager, + ) + raw_backup_path = await exporter.export_all() + # 重命名为含 "update_backup" 标记的文件名,以便清理逻辑正确识别 + backup_dir = os.path.dirname(raw_backup_path) + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_filename = ( + f"astrbot_update_backup_v{VERSION}_{timestamp}.zip" + ) + backup_path = os.path.join(backup_dir, backup_filename) + os.rename(raw_backup_path, backup_path) + logger.info(f"更新前备份已创建: {backup_path}") + except Exception as backup_exc: + logger.warning(f"创建备份失败(继续更新): {backup_exc}") + + # 2. 下载并应用更新 + try: + logger.info(f"自动更新触发,目标版本: {target_version or 'latest'}") + updator: AstrBotUpdator = self.core_lifecycle.astrbot_updator + await updator.update( + reboot=True, + latest=target_version is None, + version=target_version, + ) + except Exception as exc: + logger.error(f"自动更新失败: {exc}", exc_info=True) + + # 3. 自动回滚 + if backup_path and os.path.exists(backup_path): + try: + logger.info("正在从备份恢复...") + kb_manager = getattr(self.core_lifecycle, "kb_manager", None) + importer = AstrBotImporter( + main_db=self.core_lifecycle.db, + kb_manager=kb_manager, + ) + result = await importer.import_all( + zip_path=backup_path, + mode="replace", + ) + if result.success: + logger.info("已从备份恢复数据。") + else: + logger.error(f"从备份恢复失败: {'; '.join(result.errors)}") + except Exception as rollback_exc: + logger.error( + f"自动回滚失败,请手动恢复备份: {backup_path}。" + f"错误: {rollback_exc}" + ) + raise + + # ---- 备份清理 ---- + + async def _backup_cleanup_loop(self) -> None: + """备份清理循环。每小时检查一次。""" + await asyncio.sleep(120) # 启动后等 2 分钟 + + while True: + try: + await self._cleanup_old_backups() + except asyncio.CancelledError: + return + except Exception: + logger.warning("备份清理失败。", exc_info=True) + + await asyncio.sleep(3600) # 每小时 + + async def _cleanup_old_backups(self) -> None: + """清理超过保留期限的更新备份。""" + backup_dir = get_astrbot_backups_path() + if not os.path.isdir(backup_dir): + return + + retention_seconds = self.backup_retention_days * 86400 + now = time.time() + deleted_count = 0 + + # 收集更新备份文件 + update_backups: list[tuple[str, float]] = [] + for filename in os.listdir(backup_dir): + if not filename.endswith(".zip"): + continue + # 只清理更新前自动创建的备份 + if "update_backup" not in filename: + continue + file_path = os.path.join(backup_dir, filename) + try: + mtime = os.path.getmtime(file_path) + update_backups.append((file_path, mtime)) + except OSError: + pass + + if len(update_backups) <= 1: + return # 保留至少一个备份 + + # 按时间排序(新的在前) + update_backups.sort(key=lambda x: x[1], reverse=True) + + # 保留最近一个,其余过期的删除 + for file_path, mtime in update_backups[1:]: + if now - mtime > retention_seconds: + try: + os.remove(file_path) + deleted_count += 1 + logger.info(f"已删除过期更新备份: {os.path.basename(file_path)}") + except OSError as exc: + logger.warning(f"删除过期备份失败 {file_path}: {exc}") + + if deleted_count > 0: + logger.info(f"备份清理完成,删除了 {deleted_count} 个过期备份。") + + # ---- 公共 API ---- + + async def check_now(self) -> dict: + """立即检查更新(供聊天指令调用)。 + + Returns: + dict: 包含 has_update, current_version, latest_version 等信息。 + """ + updator = getattr(self.core_lifecycle, "astrbot_updator", None) + if not updator: + return { + "has_update": False, + "current_version": VERSION, + "error": "更新器不可用", + } + + try: + result = await updator.check_update(None, None, True) + except Exception as exc: + return { + "has_update": False, + "current_version": VERSION, + "error": str(exc), + } + + if result is not None: + self._new_version_available = result.version + return { + "has_update": True, + "current_version": VERSION, + "latest_version": result.version, + "published_at": result.published_at, + "body": result.body, + } + + return { + "has_update": False, + "current_version": VERSION, + "latest_version": VERSION, + } + + async def trigger_update(self, version: str | None = None) -> None: + """触发更新流程。 + + Args: + version: 目标版本,None 表示最新版本。 + """ + await self._trigger_auto_update(version) + + def get_new_version_info(self) -> str | None: + """获取已知的新版本号。""" + return self._new_version_available diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 9d67d40733..ab71d63a29 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -272,6 +272,13 @@ "ca_certs": "", }, }, + "auto_update": { + "enabled": False, + "check_interval": 86400, # 检查间隔(秒),默认 24 小时 + "backup_retention_days": 14, # 更新备份保留天数 + "auto_backup_before_update": True, # 更新前自动备份 + "notify_on_new_version": True, # 发现新版本时通知管理员 + }, "platform": [], "platform_specific": { # 平台特异配置:按平台分类,平台下按功能分组 diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index c325a2ea38..85d3477ae6 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -19,6 +19,7 @@ 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.config.default import VERSION from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.cron import CronJobManager @@ -59,6 +60,7 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None self.temp_dir_cleaner: TempDirCleaner | None = None + self.auto_update_manager: AutoUpdateManager | None = None self._default_chat_provider_warning_emitted = False # 设置代理 @@ -236,6 +238,9 @@ async def initialize(self) -> None: self.subagent_orchestrator, ) + # 把 core_lifecycle 引用暴露给 StarContext,供插件访问 + self.star_context.core_lifecycle = self + # 初始化插件管理器 self.plugin_manager = PluginManager(self.star_context, self.astrbot_config) @@ -255,6 +260,9 @@ async def initialize(self) -> None: # 初始化更新器 self.astrbot_updator = AstrBotUpdator() + # 初始化自动更新管理器 + self.auto_update_manager = AutoUpdateManager(self) + # 初始化事件总线 self.event_bus = EventBus( self.event_queue, @@ -297,6 +305,14 @@ def _load(self) -> None: name="temp_dir_cleaner", ) + # 启动自动更新管理器(版本检查 + 备份清理) + auto_update_task = None + if self.auto_update_manager: + auto_update_task = asyncio.create_task( + self.auto_update_manager.start_background_tasks(), + name="auto_update_manager", + ) + # 把插件中注册的所有协程函数注册到事件总线中并执行 extra_tasks = [] for task in self.star_context._register_tasks: @@ -307,6 +323,8 @@ def _load(self) -> None: tasks_.append(cron_task) if temp_dir_cleaner_task: tasks_.append(temp_dir_cleaner_task) + if auto_update_task: + tasks_.append(auto_update_task) for task in tasks_: self.curr_tasks.append( asyncio.create_task(self._task_wrapper(task), name=task.get_name()), @@ -361,6 +379,9 @@ async def stop(self) -> None: if self.temp_dir_cleaner: await self.temp_dir_cleaner.stop() + if self.auto_update_manager: + await self.auto_update_manager.stop_background_tasks() + # 请求停止所有正在运行的异步任务 for task in self.curr_tasks: task.cancel() diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py index 07a7e5c54c..be56af2847 100644 --- a/astrbot/dashboard/services/update_service.py +++ b/astrbot/dashboard/services/update_service.py @@ -13,10 +13,13 @@ from astrbot.core import DEMO_MODE as _DEMO_MODE from astrbot.core import logger from astrbot.core import pip_installer as _pip_installer +from astrbot.core.backup.exporter import AstrBotExporter +from astrbot.core.backup.importer import AstrBotImporter from astrbot.core.config.default import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.astrbot_path import ( + get_astrbot_backups_path, get_astrbot_data_path, get_astrbot_system_tmp_path, ) @@ -92,6 +95,7 @@ def __init__( self.demo_mode = demo_mode self.clear_site_data_headers = clear_site_data_headers self.update_progress: dict[str, dict] = {} + self._last_update_backup_path: str | None = None def get_update_progress(self, progress_id: str) -> UpdateServiceResult: if not progress_id: @@ -162,6 +166,19 @@ async def update_project(self, data: object) -> UpdateServiceResult: update_token = uuid.uuid4().hex dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip" core_zip_path = update_temp_dir / f"{update_token}-core.zip" + + # 更新前自动备份 + backup_path: str | None = None + config = self.core_lifecycle.astrbot_config + auto_backup = config.get("auto_update", {}).get( + "auto_backup_before_update", True + ) + if auto_backup: + try: + backup_path = await self._create_backup(progress_id) + except Exception as exc: + logger.warning(f"更新前备份失败(继续更新): {exc}") + try: self._set_update_stage( progress_id, @@ -320,6 +337,41 @@ def _verify_update_packages() -> None: }, ) logger.error(f"/api/update_project: {traceback.format_exc()}") + + # 自动回滚:如果更新前创建了备份,尝试恢复 + if backup_path: + self._set_update_stage( + progress_id, + "rollback", + "running", + "更新失败,正在自动回滚到更新前的状态...", + 0, + ) + try: + await self._restore_backup(backup_path) + self._set_update_stage( + progress_id, + "rollback", + "done", + "已自动回滚到更新前的状态。", + 100, + ) + logger.info("更新失败后已自动回滚。") + except Exception as rollback_exc: + logger.error( + f"自动回滚失败: {rollback_exc},请手动从备份恢复: {backup_path}" + ) + self.update_progress[progress_id].update( + { + "status": "error", + "stage": "rollback", + "message": ( + f"自动回滚失败,请手动从备份恢复。" + f"备份路径: {backup_path}" + ), + }, + ) + raise UpdateServiceError(exc.__str__()) from exc finally: for zip_path in (dashboard_zip_path, core_zip_path): @@ -364,6 +416,99 @@ async def install_pip_package(self, data: object) -> UpdateServiceResult: logger.error(f"/api/update_pip: {traceback.format_exc()}") raise UpdateServiceError(exc.__str__()) from exc + async def _create_backup(self, progress_id: str) -> str: + """在更新前创建完整备份。 + + Returns: + str: 备份文件路径。 + + Raises: + Exception: 备份创建失败时抛出。 + """ + import os + from datetime import datetime + + self._set_update_stage( + progress_id, + "backup", + "running", + "正在创建更新前备份...", + 0, + ) + + kb_manager = getattr(self.core_lifecycle, "kb_manager", None) + data_dir = get_astrbot_data_path() + config_path = os.path.join(data_dir, "cmd_config.json") + backup_dir = get_astrbot_backups_path() + + exporter = AstrBotExporter( + main_db=self.core_lifecycle.db, + kb_manager=kb_manager, + config_path=config_path, + ) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_filename = f"astrbot_update_backup_v{VERSION}_{timestamp}.zip" + + # 直接导出,使用自定义文件名 + zip_path = os.path.join(backup_dir, backup_filename) + zip_path = await exporter.export_all(output_dir=backup_dir) + + # 重命名为带版本标记的文件名 + if os.path.basename(zip_path) != backup_filename: + final_path = os.path.join(backup_dir, backup_filename) + os.rename(zip_path, final_path) + zip_path = final_path + + self._last_update_backup_path = zip_path + + self._set_update_stage( + progress_id, + "backup", + "done", + f"更新前备份完成: {backup_filename}", + 5, + ) + logger.info(f"更新前备份已创建: {zip_path}") + return zip_path + + async def _restore_backup(self, backup_path: str) -> None: + """从备份恢复数据。 + + Args: + backup_path: 备份 ZIP 文件路径。 + """ + import os + + if not os.path.exists(backup_path): + raise FileNotFoundError(f"备份文件不存在: {backup_path}") + + kb_manager = getattr(self.core_lifecycle, "kb_manager", None) + data_dir = get_astrbot_data_path() + config_path = os.path.join(data_dir, "cmd_config.json") + + importer = AstrBotImporter( + main_db=self.core_lifecycle.db, + kb_manager=kb_manager, + config_path=config_path, + ) + + # 先做前置检查 + pre_check = importer.pre_check(backup_path) + if not pre_check.can_import: + raise UpdateServiceError(f"备份文件无法导入: {'; '.join(pre_check.errors)}") + + # 执行导入(replace 模式:清空现有数据后导入) + result = await importer.import_all( + zip_path=backup_path, + mode="replace", + ) + + if not result.success: + raise UpdateServiceError(f"从备份恢复失败: {'; '.join(result.errors)}") + + logger.info(f"已从备份恢复数据: {backup_path}") + def _init_update_progress(self, progress_id: str, version: str) -> None: self.update_progress[progress_id] = { "id": progress_id, diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index e7427f1edb..941d17c552 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -132,6 +132,42 @@ } } }, + "autoUpdate": { + "title": "Auto Update", + "enabled": { + "title": "Automatic Updates", + "subtitle": "When enabled, AstrBot will automatically download and apply updates when a new version is detected. A backup is created before each update, and the bot will rollback automatically on failure." + }, + "checkInterval": { + "title": "Check Interval (hours)", + "subtitle": "How often to check for new versions." + }, + "backupRetention": { + "title": "Backup Retention (days)", + "subtitle": "Update backups older than this will be automatically deleted. The most recent backup is always kept." + }, + "autoBackup": { + "title": "Backup Before Update", + "subtitle": "Create a full data backup before applying any update." + }, + "notify": { + "title": "New Version Notification", + "subtitle": "Send a notification to admins via IM platforms when a new version is detected." + }, + "checkNow": "Check Now", + "updateNow": "Update Now", + "checkForUpdate": { + "checking": "Checking for updates...", + "latest": "You are running the latest version.", + "newVersionAvailable": "A new version is available! (Current version: {version})", + "failed": "Failed to check for updates" + }, + "actions": { + "save": "Save Settings", + "saved": "Saved", + "saveFailed": "Save failed" + } + }, "sidebar": { "title": "Sidebar", "customize": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 9784aa9787..1dcd4d2de9 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -132,6 +132,42 @@ } } }, + "autoUpdate": { + "title": "自动更新", + "enabled": { + "title": "启用自动更新", + "subtitle": "开启后,AstrBot 会在检测到新版本时自动下载并应用更新。每次更新前会自动备份数据,更新失败时自动回滚。" + }, + "checkInterval": { + "title": "检查间隔(小时)", + "subtitle": "检查新版本的频率。" + }, + "backupRetention": { + "title": "备份保留(天)", + "subtitle": "超过此天数的更新备份将被自动清理,始终保留最近一个备份。" + }, + "autoBackup": { + "title": "更新前自动备份", + "subtitle": "在应用任何更新之前创建完整数据备份。" + }, + "notify": { + "title": "新版本通知", + "subtitle": "检测到新版本时通过消息平台通知管理员。" + }, + "checkNow": "立即检查", + "updateNow": "立即更新", + "checkForUpdate": { + "checking": "正在检查更新...", + "latest": "您正在运行最新版本。", + "newVersionAvailable": "发现新版本!(当前版本:{version})", + "failed": "检查更新失败" + }, + "actions": { + "save": "保存设置", + "saved": "已保存", + "saveFailed": "保存失败" + } + }, "sidebar": { "title": "侧边栏", "customize": { diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 7a954c33e2..12e39f5b5d 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -231,6 +231,113 @@ + +