Skip to content

feat: add auto-update with backup, rollback, and retention#8814

Open
Dr1985 wants to merge 3 commits into
AstrBotDevs:masterfrom
Dr1985:fix-for-issue-number-8686
Open

feat: add auto-update with backup, rollback, and retention#8814
Dr1985 wants to merge 3 commits into
AstrBotDevs:masterfrom
Dr1985:fix-for-issue-number-8686

Conversation

@Dr1985

@Dr1985 Dr1985 commented Jun 16, 2026

Copy link
Copy Markdown

Closes #8686

Summary

Add an automatic update system to AstrBot that periodically checks for new versions, notifies admins, creates backups before updating, auto-rolls back on failure, and cleans up old backups after a configurable retention period.

Users can choose between manual update (via chat command or WebUI) and automatic update (configurable toggle in Settings).

Changes

New Files

File Purpose
astrbot/core/auto_update.py Core auto-update manager: version check loop, backup cleanup loop, auto-update trigger with backup/rollback
astrbot/builtin_stars/builtin_commands/commands/update.py Chat command handlers for /update check, /update now, /update auto on/off, /update status

How to Test

Prerequisites

uv sync
mkdir -p data/plugins data/config data/temp
uv run main.py  # starts at http://localhost:6185

Test 1: WebUI Settings Page

  1. Open http://localhost:6185, login (default: astrbot / your password)
  2. Navigate to Settings page
  3. Verify the Auto Update section appears between System and API Key
  4. Toggle "Automatic Updates" switch → should save immediately (toast: "Saved")
  5. Change "Check Interval" to another value → saves after 800ms debounce
  6. Change "Backup Retention" → same behavior
  7. Toggle "Backup Before Update" and "New Version Notification"
  8. Click "Check Now" → shows toast with result ("latest version" or "new version available")
  9. Refresh page → all values should persist

Test 2: Chat Commands

  1. Connect AstrBot to any messaging platform (e.g., WebChat)
  2. As an admin user, send /update status → should show version, auto-update config, and available commands
  3. Send /update check → should check for new version and report result
  4. Send /update auto on → should enable auto-update ("Auto-update is now ENABLED")
  5. Send /update auto off → should disable auto-update ("Auto-update is now DISABLED")
  6. Send /update (no subcommand) → should show help text with subcommand list
  7. Send /update nonsense → should show "Unknown subcommand" with usage

Test 3: Backup on Update (via WebUI)

  1. In Settings, ensure "Backup Before Update" is ON
  2. Go to the Update page in the sidebar
  3. Click "Check Update" → should show current version status
  4. If an update is available, run it
  5. Check data/backups/ directory → should contain a astrbot_backup_*.zip file (the pre-update backup)
  6. Verify the ZIP contains all expected data (database, config, plugins, etc.)

Test 4: Backup Cleanup

  1. In Settings, set "Backup Retention" to 0 (simulates immediate expiration)
  2. Wait ~2 minutes for the cleanup loop to start
  3. Check logs → should show cleanup of old update backups
  4. The most recent backup should be preserved
  5. Set retention back to 14

Test 5: Auto-Update Trigger (simulated)

  1. Verify auto_update.enabled is false by default in data/cmd_config.json
  2. Set it to true manually, or toggle in Settings
  3. The version check loop runs after 60s startup delay + configured interval
  4. If a new version is detected, admins receive notification, and update triggers after 30s
  5. Logs should show: "AutoUpdateManager background tasks started."

Test 6: Rollback Simulation

  1. To simulate rollback, intentionally corrupt the update process (e.g., provide invalid version)
  2. Use /update now invalid-version-tag
  3. The update should fail
  4. If backup existed, system should log "正在从备份恢复..." and restore data
  5. If rollback fails, the backup path is logged for manual recovery

Test Results

Environment

  • OS: Windows 11 Pro (10.0.26200)
  • Python: 3.12.7
  • Node.js: v24.13.1

Unit Tests

591 passed, 5 failed in 22.99s

5 failures are all pre-existing Windows platform issues (path separators, line endings, shell quoting) — not caused by this change. Detailed analysis: juboxiang to do/failing-tests-windows.md

Integration Tests

787 passed, 27 failed, 100 deselected in 222.05s

27 failures are all pre-existing Windows platform issues (hardcoded C:\Users\admin paths, symlink permissions, \r\n line endings, file URI resolution). Detailed analysis: juboxiang to do/failing-tests-windows.md

Dashboard Build

✓ built in 2m 11s — no TypeScript errors

Lint

ruff check: All checks passed!
ruff format: all files formatted

Architecture Notes

Auto-Update Flow

main.py → InitialLoader.start()
  → AstrBotCoreLifecycle.initialize()
    → AstrBotUpdator()                     # existing updater
    → AutoUpdateManager(self)              # NEW: creates manager
  → AstrBotCoreLifecycle._load()
    → auto_update_manager.start_background_tasks()  # NEW: starts loops
  → AstrBotDashboard.run()                 # serves WebUI

AutoUpdateManager background tasks:
_version_check_loop: sleep(60) → check → sleep(interval) → check → ...
_backup_cleanup_loop: sleep(120) → cleanup → sleep(3600) → cleanup → ...

On new version detected:

  1. notify_admins_new_version() → IM messages
  2. if enabled: sleep(30) → _trigger_auto_update()
    a. _create_backup() → AstrBotExporter.export_all()
    b. updator.update(reboot=True) → download + apply
    c. On failure: AstrBotImporter.import_all() → restore backup

On manual update (WebUI / chat command):
UpdateService.update_project() → same backup/rollback flow

Reused Existing Components

  • AstrBotUpdator (astrbot/core/updator.py): version check, download, apply
  • AstrBotExporter (astrbot/core/backup/exporter.py): full data backup
  • AstrBotImporter (astrbot/core/backup/importer.py): data restore
  • UpdateService (astrbot/dashboard/services/update_service.py): update orchestration
  • CronJobManager pattern: async background task scheduling

Design Decisions

  1. Why not use CronJobManager? Cron jobs are user-facing (DB-backed, UI-managed). Auto-update is a system-level task — simpler as asyncio.create_task() loops.

  2. Why backup only data, not code? The AstrBotExporter backs up all user data (DB, configs, plugins, knowledge bases). Code updates come from the release zip and are already versioned; if the update corrupts code, re-downloading is faster than restoring from backup.

  3. Why 30s delay before auto-update? Gives admins time to see the notification and cancel if needed (the delay also ensures messages are delivered before reboot).

  4. uv tool users: Already guarded by AstrBotUpdatorASTRBOT_CLI env var check prevents update, directs to uv tool upgrade.

Modifications / 改动点

Add an automatic update system to AstrBot that periodically checks for new versions, notifies admins, creates backups before updating, auto-rolls back on failure, and cleans up old backups after a configurable retention period.

Users can choose between manual update (via chat command or WebUI) and automatic update (configurable toggle in Settings).

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

before the changes, there are no auto-update in the settings page.
63eaaf72bc79683c4a347b7abfd1aa17

after the changes, there is auto-update in the settings page.
f80419ce480a60818ed1c31f80a92d8b
a6c81d2abad0a889879f092c4732c7dd
7fdfce77cb5c8ea34853ab0df3e02f90


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Introduce an automatic update system with configurable scheduling, backup/rollback safeguards, and both WebUI and chat-based controls.

New Features:

  • Add an AutoUpdateManager core component that periodically checks for new versions, can trigger automatic updates, and exposes a programmatic API for manual checks and updates.
  • Expose auto-update configuration and one-click update checks in the Settings dashboard, including toggles for enabling auto-update, backup behavior, notifications, and tunable intervals/retention.
  • Provide /update chat commands for admins to check for updates, trigger immediate updates, toggle auto-update, and inspect update status.

Enhancements:

  • Extend the update service to create pre-update backups and perform automatic rollback from backup on update failure, with user-visible progress reporting.
  • Persist auto-update configuration in the core runtime config with sensible defaults and integrate the auto-update manager into the core lifecycle startup/shutdown.
  • Add automated cleanup of old update backups based on a configurable retention period, preserving at least the most recent backup.
  • Update the icon subset and i18n settings resources to support the new auto-update UI actions.

Closes AstrBotDevs#8686

Summary
Add an automatic update system to AstrBot that periodically checks for new versions, notifies admins, creates backups before updating, auto-rolls back on failure, and cleans up old backups after a configurable retention period.

Users can choose between manual update (via chat command or WebUI) and automatic update (configurable toggle in Settings).
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:updater The bug / feature is about astrbot updater system labels Jun 16, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In the Settings auto-update panel, the backupRetention text field enforces min="1" but your test plan describes using a value of 0 to simulate immediate expiration; if 0 is a valid and supported value, the frontend validation should allow it (or the test plan and backend constraints should be aligned).
  • The checkForUpdate method in Settings.vue uses hard-coded English strings for toast messages instead of the existing i18n translation mechanism (tm(...)); consider moving these user-facing messages into the locale files for consistency with the rest of the UI.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the Settings auto-update panel, the `backupRetention` text field enforces `min="1"` but your test plan describes using a value of 0 to simulate immediate expiration; if 0 is a valid and supported value, the frontend validation should allow it (or the test plan and backend constraints should be aligned).
- The `checkForUpdate` method in `Settings.vue` uses hard-coded English strings for toast messages instead of the existing i18n translation mechanism (`tm(...)`); consider moving these user-facing messages into the locale files for consistency with the rest of the UI.

## Individual Comments

### Comment 1
<location path="astrbot/core/auto_update.py" line_range="173-182" />
<code_context>
+        创建备份 → 下载更新 → 应用更新 → 重启。
+        如果更新过程中发生错误,自动回滚到备份。
+        """
+        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,
+                )
+                backup_path = await exporter.export_all()
+                logger.info(f"更新前备份已创建: {backup_path}")
+            except Exception as backup_exc:
</code_context>
<issue_to_address>
**issue (bug_risk):** Align auto-update backup naming with the cleanup logic so these backups can be pruned.

`export_all()` uses the exporter’s default filename pattern, but `_cleanup_old_backups` only matches names containing `"update_backup"`, so these auto-update backups are never pruned and can accumulate. Please either (a) pass a filename pattern including `update_backup` (like `UpdateService._create_backup`) or (b) broaden the cleanup filter to also match these filenames, while still ensuring at least one recent backup is retained.
</issue_to_address>

### Comment 2
<location path="astrbot/dashboard/services/update_service.py" line_range="207" />
<code_context>
         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
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the new backup/rollback logic and shared setup into dedicated helpers and using top-level imports to keep `UpdateService.update_project` more linear and readable.

The added backup/rollback flow does increase the responsibility and branching inside `UpdateService`. You can keep all behavior but reduce complexity by:

1. Extracting rollback behavior into a dedicated method to keep `update_project` linear.
2. Sharing common backup/import wiring in small helpers instead of repeating setup.
3. Removing unnecessary dynamic imports and moving imports to module scope.

### 1) Encapsulate rollback flow

`update_project`’s `except` block is long and deeply nested. You can move the rollback part into a small internal method:

```python
# inside UpdateService

async def _auto_rollback_from_backup(
    self,
    progress_id: str,
    backup_path: str,
    original_exc: Exception,
) -> None:
    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"备份路径: {backup_path}"
                ),
            },
        )
        # keep original raise behavior; you still raise after this in update_project
```

Then `update_project`’s `except` block becomes simpler:

```python
except Exception as exc:
    self.update_progress[progress_id].update(
        {
            "status": "error",
            "message": "更新失败,请查看服务端日志。",
        },
    )
    logger.error(f"/api/update_project: {traceback.format_exc()}")

    if backup_path:
        await self._auto_rollback_from_backup(progress_id, backup_path, exc)

    raise UpdateServiceError(str(exc)) from exc
```

This keeps the main flow readable while preserving all rollback semantics.

### 2) Share backup/import wiring

Both `_create_backup` and `_restore_backup` reconstruct `kb_manager`, `data_dir`, and `config_path`. Wrapping that in a helper reduces duplication and makes future changes (e.g. config path change) one‑place:

```python
# inside UpdateService

def _build_backup_context(self) -> dict:
    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")
    return {
        "kb_manager": kb_manager,
        "config_path": config_path,
    }
```

Use it in both methods:

```python
async def _create_backup(self, progress_id: str) -> str:
    self._set_update_stage(
        progress_id,
        "backup",
        "running",
        "正在创建更新前备份...",
        0,
    )

    ctx = self._build_backup_context()
    exporter = AstrBotExporter(
        main_db=self.core_lifecycle.db,
        kb_manager=ctx["kb_manager"],
        config_path=ctx["config_path"],
    )
    ...
```

```python
async def _restore_backup(self, backup_path: str) -> None:
    if not os.path.exists(backup_path):
        raise FileNotFoundError(f"备份文件不存在: {backup_path}")

    ctx = self._build_backup_context()
    importer = AstrBotImporter(
        main_db=self.core_lifecycle.db,
        kb_manager=ctx["kb_manager"],
        config_path=ctx["config_path"],
    )
    ...
```

This doesn’t change behavior but reduces cognitive load.

### 3) Simplify backup naming and imports

You already import `VERSION` at module level; the dynamic import in `_create_backup` is unnecessary noise. Also, `os`/`datetime` can be imported once at the top:

```python
# top of file
import os
from datetime import datetime
...
from astrbot.core.config.default import VERSION
```

Then in `_create_backup`:

```python
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
version_str = VERSION or "pre_update"
backup_filename = f"astrbot_update_backup_v{version_str}_{timestamp}.zip"
```

This removes reflection-style imports and keeps configuration knowledge in one place.

These small extractions should significantly reduce perceived complexity in `UpdateService` without changing any observable behavior or backing out the new feature.
</issue_to_address>

### Comment 3
<location path="astrbot/core/auto_update.py" line_range="167" />
<code_context>
+            except Exception:
+                pass
+
+    async def _trigger_auto_update(self, target_version: str | None = None) -> None:
+        """触发自动更新流程。
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting shared backup/rollback logic and periodic-loop behavior into dedicated helpers so AutoUpdateManager mainly coordinates updates instead of implementing all details itself.

You can trim a lot of complexity here by (1) centralizing backup/rollback behavior and (2) extracting shared loop behavior. This keeps `AutoUpdateManager` focused on scheduling/coordination and avoids duplicating update logic that likely already lives in `UpdateService`.

### 1. Extract backup / rollback into a shared helper

`_trigger_auto_update` wires `AstrBotExporter` / `AstrBotImporter` and handles rollback decisions. That’s very similar in intent to the dashboard update backup helpers. You can centralize this into a small helper object/module and reuse it from both `AutoUpdateManager` and `UpdateService`.

Example of a shared helper:

```python
# astrbot/core/update_backup.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from astrbot.core.core_lifecycle import AstrBotCoreLifecycle

from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.backup.importer import AstrBotImporter
from astrbot.api import logger


class UpdateBackupManager:
    def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
        self.core_lifecycle = core_lifecycle

    async def create_update_backup(self) -> str | None:
        kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
        exporter = AstrBotExporter(
            main_db=self.core_lifecycle.db,
            kb_manager=kb_manager,
        )
        try:
            backup_path = await exporter.export_all()
            logger.info(f"更新前备份已创建: {backup_path}")
            return backup_path
        except Exception as exc:
            logger.warning(f"创建更新前备份失败: {exc}", exc_info=True)
            return None

    async def restore_from_backup(self, backup_path: str) -> None:
        from os.path import exists

        if not exists(backup_path):
            logger.warning(f"备份文件不存在,无法回滚: {backup_path}")
            return

        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)}")
```

Then `AutoUpdateManager._trigger_auto_update` becomes smaller and only orchestrates:

```python
# in AutoUpdateManager.__init__
from astrbot.core.update_backup import UpdateBackupManager

def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
    self.core_lifecycle = core_lifecycle
    self._backup_mgr = UpdateBackupManager(core_lifecycle)
    ...

async def _trigger_auto_update(self, target_version: str | None = None) -> None:
    backup_path: str | None = None
    auto_backup = self.config.get("auto_backup_before_update", True)

    if auto_backup:
        backup_path = await self._backup_mgr.create_update_backup()

    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)
        if backup_path:
            try:
                await self._backup_mgr.restore_from_backup(backup_path)
            except Exception as rollback_exc:
                logger.error(
                    f"自动回滚失败,请手动恢复备份: {backup_path}。错误: {rollback_exc}"
                )
        raise
```

The same `UpdateBackupManager` can be used by `UpdateService` so there is a single place that knows how to do “update backup + rollback”.

### 2. Factor out common loop behavior

Both `_version_check_loop` and `_backup_cleanup_loop` are long loops with similar `while True` / `sleep` / exception handling patterns. Extracting a small “periodic task runner” keeps `AutoUpdateManager` lighter and makes lifecycle behavior easier to reason about.

For instance:

```python
# a small internal helper (can be in the same module)
async def _run_periodic(
    interval_seconds: int,
    task_fn,
    initial_delay: int = 0,
    warning_msg: str | None = None,
) -> None:
    if initial_delay > 0:
        await asyncio.sleep(initial_delay)

    while True:
        try:
            await task_fn()
        except asyncio.CancelledError:
            return
        except Exception:
            if warning_msg:
                logger.warning(warning_msg, exc_info=True)
        await asyncio.sleep(interval_seconds)
```

Then your loops reduce to:

```python
async def _version_check_loop(self) -> None:
    await _run_periodic(
        interval_seconds=self.check_interval,
        task_fn=self._do_version_check,
        initial_delay=60,
        warning_msg="版本检查失败(不影响正常使用)。",
    )

async def _backup_cleanup_loop(self) -> None:
    await _run_periodic(
        interval_seconds=3600,
        task_fn=self._cleanup_old_backups,
        initial_delay=120,
        warning_msg="备份清理失败。",
    )
```

This keeps all existing behavior (including delays and logging) but makes `AutoUpdateManager` mostly a composition of smaller, single-purpose pieces, addressing the concern that it owns too many domains directly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/auto_update.py
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"

# 更新前自动备份

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the new backup/rollback logic and shared setup into dedicated helpers and using top-level imports to keep UpdateService.update_project more linear and readable.

The added backup/rollback flow does increase the responsibility and branching inside UpdateService. You can keep all behavior but reduce complexity by:

  1. Extracting rollback behavior into a dedicated method to keep update_project linear.
  2. Sharing common backup/import wiring in small helpers instead of repeating setup.
  3. Removing unnecessary dynamic imports and moving imports to module scope.

1) Encapsulate rollback flow

update_project’s except block is long and deeply nested. You can move the rollback part into a small internal method:

# inside UpdateService

async def _auto_rollback_from_backup(
    self,
    progress_id: str,
    backup_path: str,
    original_exc: Exception,
) -> None:
    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"备份路径: {backup_path}"
                ),
            },
        )
        # keep original raise behavior; you still raise after this in update_project

Then update_project’s except block becomes simpler:

except Exception as exc:
    self.update_progress[progress_id].update(
        {
            "status": "error",
            "message": "更新失败,请查看服务端日志。",
        },
    )
    logger.error(f"/api/update_project: {traceback.format_exc()}")

    if backup_path:
        await self._auto_rollback_from_backup(progress_id, backup_path, exc)

    raise UpdateServiceError(str(exc)) from exc

This keeps the main flow readable while preserving all rollback semantics.

2) Share backup/import wiring

Both _create_backup and _restore_backup reconstruct kb_manager, data_dir, and config_path. Wrapping that in a helper reduces duplication and makes future changes (e.g. config path change) one‑place:

# inside UpdateService

def _build_backup_context(self) -> dict:
    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")
    return {
        "kb_manager": kb_manager,
        "config_path": config_path,
    }

Use it in both methods:

async def _create_backup(self, progress_id: str) -> str:
    self._set_update_stage(
        progress_id,
        "backup",
        "running",
        "正在创建更新前备份...",
        0,
    )

    ctx = self._build_backup_context()
    exporter = AstrBotExporter(
        main_db=self.core_lifecycle.db,
        kb_manager=ctx["kb_manager"],
        config_path=ctx["config_path"],
    )
    ...
async def _restore_backup(self, backup_path: str) -> None:
    if not os.path.exists(backup_path):
        raise FileNotFoundError(f"备份文件不存在: {backup_path}")

    ctx = self._build_backup_context()
    importer = AstrBotImporter(
        main_db=self.core_lifecycle.db,
        kb_manager=ctx["kb_manager"],
        config_path=ctx["config_path"],
    )
    ...

This doesn’t change behavior but reduces cognitive load.

3) Simplify backup naming and imports

You already import VERSION at module level; the dynamic import in _create_backup is unnecessary noise. Also, os/datetime can be imported once at the top:

# top of file
import os
from datetime import datetime
...
from astrbot.core.config.default import VERSION

Then in _create_backup:

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
version_str = VERSION or "pre_update"
backup_filename = f"astrbot_update_backup_v{version_str}_{timestamp}.zip"

This removes reflection-style imports and keeps configuration knowledge in one place.

These small extractions should significantly reduce perceived complexity in UpdateService without changing any observable behavior or backing out the new feature.

except Exception:
pass

async def _trigger_auto_update(self, target_version: str | None = None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting shared backup/rollback logic and periodic-loop behavior into dedicated helpers so AutoUpdateManager mainly coordinates updates instead of implementing all details itself.

You can trim a lot of complexity here by (1) centralizing backup/rollback behavior and (2) extracting shared loop behavior. This keeps AutoUpdateManager focused on scheduling/coordination and avoids duplicating update logic that likely already lives in UpdateService.

1. Extract backup / rollback into a shared helper

_trigger_auto_update wires AstrBotExporter / AstrBotImporter and handles rollback decisions. That’s very similar in intent to the dashboard update backup helpers. You can centralize this into a small helper object/module and reuse it from both AutoUpdateManager and UpdateService.

Example of a shared helper:

# astrbot/core/update_backup.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from astrbot.core.core_lifecycle import AstrBotCoreLifecycle

from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.backup.importer import AstrBotImporter
from astrbot.api import logger


class UpdateBackupManager:
    def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
        self.core_lifecycle = core_lifecycle

    async def create_update_backup(self) -> str | None:
        kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
        exporter = AstrBotExporter(
            main_db=self.core_lifecycle.db,
            kb_manager=kb_manager,
        )
        try:
            backup_path = await exporter.export_all()
            logger.info(f"更新前备份已创建: {backup_path}")
            return backup_path
        except Exception as exc:
            logger.warning(f"创建更新前备份失败: {exc}", exc_info=True)
            return None

    async def restore_from_backup(self, backup_path: str) -> None:
        from os.path import exists

        if not exists(backup_path):
            logger.warning(f"备份文件不存在,无法回滚: {backup_path}")
            return

        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)}")

Then AutoUpdateManager._trigger_auto_update becomes smaller and only orchestrates:

# in AutoUpdateManager.__init__
from astrbot.core.update_backup import UpdateBackupManager

def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
    self.core_lifecycle = core_lifecycle
    self._backup_mgr = UpdateBackupManager(core_lifecycle)
    ...

async def _trigger_auto_update(self, target_version: str | None = None) -> None:
    backup_path: str | None = None
    auto_backup = self.config.get("auto_backup_before_update", True)

    if auto_backup:
        backup_path = await self._backup_mgr.create_update_backup()

    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)
        if backup_path:
            try:
                await self._backup_mgr.restore_from_backup(backup_path)
            except Exception as rollback_exc:
                logger.error(
                    f"自动回滚失败,请手动恢复备份: {backup_path}。错误: {rollback_exc}"
                )
        raise

The same UpdateBackupManager can be used by UpdateService so there is a single place that knows how to do “update backup + rollback”.

2. Factor out common loop behavior

Both _version_check_loop and _backup_cleanup_loop are long loops with similar while True / sleep / exception handling patterns. Extracting a small “periodic task runner” keeps AutoUpdateManager lighter and makes lifecycle behavior easier to reason about.

For instance:

# a small internal helper (can be in the same module)
async def _run_periodic(
    interval_seconds: int,
    task_fn,
    initial_delay: int = 0,
    warning_msg: str | None = None,
) -> None:
    if initial_delay > 0:
        await asyncio.sleep(initial_delay)

    while True:
        try:
            await task_fn()
        except asyncio.CancelledError:
            return
        except Exception:
            if warning_msg:
                logger.warning(warning_msg, exc_info=True)
        await asyncio.sleep(interval_seconds)

Then your loops reduce to:

async def _version_check_loop(self) -> None:
    await _run_periodic(
        interval_seconds=self.check_interval,
        task_fn=self._do_version_check,
        initial_delay=60,
        warning_msg="版本检查失败(不影响正常使用)。",
    )

async def _backup_cleanup_loop(self) -> None:
    await _run_periodic(
        interval_seconds=3600,
        task_fn=self._cleanup_old_backups,
        initial_delay=120,
        warning_msg="备份清理失败。",
    )

This keeps all existing behavior (including delays and logging) but makes AutoUpdateManager mostly a composition of smaller, single-purpose pieces, addressing the concern that it owns too many domains directly.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an automatic update module for AstrBot, featuring scheduled version checks, administrator notifications, pre-update backups with automatic rollback on failure, and a corresponding settings panel in the WebUI alongside a new /update command suite. The review feedback identifies several key issues and improvement opportunities: a mismatch in backup filenames that prevents automatic cleanup and could exhaust disk space; a lack of validation for update intervals and retention settings that could lead to crashes or infinite loops; a misleading version number displayed in the update check Toast notification; the risk of the asynchronous update task being garbage-collected prematurely; and redundant dynamic imports of the VERSION constant.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +180 to +191
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,
)
backup_path = await exporter.export_all()
logger.info(f"更新前备份已创建: {backup_path}")
except Exception as backup_exc:
logger.warning(f"创建备份失败(继续更新): {backup_exc}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在自动更新流程中,exporter.export_all() 默认创建的备份文件名格式为 astrbot_backup_{timestamp}.zip。然而,自动清理逻辑 _cleanup_old_backups 只会扫描并清理文件名中包含 "update_backup" 的 ZIP 文件。这会导致自动更新产生的备份文件永远不会被清理,从而随着时间推移占满磁盘空间。\n\n建议在备份创建成功后,将其重命名为包含 "update_backup" 的文件名,以便能够被自动清理逻辑正确识别和管理。

        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}")

Comment thread astrbot/core/auto_update.py Outdated
Comment on lines +49 to +54
def check_interval(self) -> int:
return self.config.get("check_interval", 86400)

@property
def backup_retention_days(self) -> int:
return self.config.get("backup_retention_days", 14)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

如果用户在 WebUI 中将检查间隔或备份保留天数清空或设置为无效值,配置中可能会保存为 0nullNaN。这会导致 _version_check_loopasyncio.sleep(0) 下无休止地循环(导致 100% CPU 占用),或者因为 TypeError 导致后台任务崩溃。\n\n建议在属性读取器中加入健壮的类型转换和有效值校验,提供安全的默认值。

Suggested change
def check_interval(self) -> int:
return self.config.get("check_interval", 86400)
@property
def backup_retention_days(self) -> int:
return self.config.get("backup_retention_days", 14)
@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

Comment thread dashboard/src/views/Settings.vue Outdated
Comment on lines +366 to +382
const saveAutoUpdateConfig = async () => {
try {
const res = await systemConfigApi.runtime();
const config = res.data?.data ?? res.data ?? {};
config.auto_update = {
enabled: autoUpdateEnabled.value,
check_interval: autoUpdateCheckInterval.value * 3600,
backup_retention_days: autoUpdateBackupRetention.value,
auto_backup_before_update: autoUpdateBackupBefore.value,
notify_on_new_version: autoUpdateNotify.value,
};
await systemConfigApi.update(config);
showToast(tm('autoUpdate.actions.saved'), 'success');
} catch {
showToast(tm('autoUpdate.actions.saveFailed'), 'error');
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在保存自动更新配置时,如果输入框被清空,autoUpdateCheckInterval.valueautoUpdateBackupRetention.value 可能会变成 NaN 或空值。这会直接保存到后端配置中,导致后台服务异常。\n\n建议在保存前进行数值转换和边界限制,确保保存的数据始终合法。

const saveAutoUpdateConfig = async () => {
    try {
        const res = await systemConfigApi.runtime();
        const config = res.data?.data ?? res.data ?? {};
        config.auto_update = {
            enabled: autoUpdateEnabled.value,
            check_interval: Math.max(1, Number(autoUpdateCheckInterval.value) || 24) * 3600,
            backup_retention_days: Math.max(0, Number(autoUpdateBackupRetention.value) || 14),
            auto_backup_before_update: autoUpdateBackupBefore.value,
            notify_on_new_version: autoUpdateNotify.value,
        };
        await systemConfigApi.update(config);
        showToast(tm('autoUpdate.actions.saved'), 'success');
    } catch {
        showToast(tm('autoUpdate.actions.saveFailed'), 'error');
    }
};

Comment thread dashboard/src/views/Settings.vue Outdated
Comment on lines +384 to +402
const checkForUpdate = async () => {
checkingUpdate.value = true;
try {
const res = await updatesApi.check();
const data = res.data?.data ?? res.data ?? {};
if (data.has_new_version) {
showToast(
`New version available: ${data.version || 'unknown'} (current: ${data.dashboard_version || 'unknown'})`,
'info',
);
} else {
showToast('You are running the latest version.', 'success');
}
} catch (e) {
showToast(e?.response?.data?.message || 'Failed to check for updates', 'error');
} finally {
checkingUpdate.value = false;
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

checkForUpdate 中,如果检测到新版本,Toast 提示中使用了 data.version 作为新版本号。然而,根据 UpdateService.check_update 的实现,data.version 返回的其实是当前运行的 Core 版本(f"v{VERSION}"),而真正的最新版本号并没有在 data 中返回。这会导致提示信息非常具有误导性(将当前版本显示为新版本)。\n\n由于后端接口限制,建议在前端提示中避免展示不正确的版本号,而是直接提示有新版本可用并展示当前版本。

const checkForUpdate = async () => {
    checkingUpdate.value = true;
    try {
        const res = await updatesApi.check();
        const data = res.data?.data ?? res.data ?? {};
        if (data.has_new_version) {
            showToast(
                `New version is available! (Current version: ${data.version || 'unknown'})`,
                'info',
            );
        } else {
            showToast('You are running the latest version.', 'success');
        }
    } catch (e) { 
        showToast(e?.response?.data?.message || 'Failed to check for updates', 'error');
    } finally {
        checkingUpdate.value = false;
    }
};

Comment on lines +91 to +99
try:
# 在后台触发更新,让当前消息先发出去
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}")
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

update_now 中,使用 asyncio.create_task 在后台异步执行更新流程。但在 Python 的 asyncio 中,如果不对创建的 Task 对象保留强引用,该任务在执行完毕前可能会被垃圾回收器(Garbage Collector)提前销毁,导致更新流程意外中断。\n\n建议将返回的 Task 对象保存到实例属性中(例如 self._update_task),以确保其生命周期安全。

Suggested change
try:
# 在后台触发更新,让当前消息先发出去
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}")
)
try:
# 在后台触发更新,让当前消息先发出去
task = asyncio.create_task(
self._trigger_update_with_delay(auto_update_mgr, version)
)
# 保存任务引用,防止被 Python 垃圾回收机制提前销毁
self._update_task = task
except Exception as exc:
await event.send(
MessageChain().message(f"❌ Failed to trigger update: {exc}")
)

Comment on lines +487 to +493
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
version_str = getattr(
__import__("astrbot.core.config.default", fromlist=["VERSION"]),
"VERSION",
"pre_update",
)
backup_filename = f"astrbot_update_backup_v{version_str}_{timestamp}.zip"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这里使用了复杂的 __import__getattr 动态导入 VERSION。然而,在文件顶部(第 18 行)已经通过 from astrbot.core.config.default import VERSION 静态导入了 VERSION。\n\n建议直接使用已导入的 VERSION 常量,避免不必要的动态导入开销 and 代码复杂度。

        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        version_str = VERSION
        backup_filename = f"astrbot_update_backup_v{version_str}_{timestamp}.zip"

@Dr1985

Dr1985 commented Jun 16, 2026

Copy link
Copy Markdown
Author

OK,我来修改一下你提到的新问题。

OK,let me fix the issues you have mensioned.

…mini Code Assist

- Fix backup filename mismatch: rename auto-update backups to include 'update_backup' so cleanup logic can match them
- Add input validation for check_interval and backup_retention_days to prevent NaN/zero values causing infinite loops
- Change backupRetention min from 1 to 0 in Settings.vue (0 is valid for immediate expiration testing)
- Add NaN/empty value guards in saveAutoUpdateConfig with Math.max/Number fallbacks
- Replace hardcoded English toast messages in checkForUpdate with i18n translations (en-US + zh-CN)
- Fix misleading version display in checkForUpdate toast (data.version is current, not latest)
- Save asyncio.create_task reference in update.py to prevent premature GC
- Remove redundant __import__ dynamic import of VERSION in update_service.py, use already-imported constant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Dr1985

Dr1985 commented Jun 16, 2026

Copy link
Copy Markdown
Author

我修改了,请问还有什么要改动的地方吗?

I have made changes on the issues you've mentioned. Is there anything more to be changed?

@sourcery-ai

- Font files: accept master (280 icons already include auto-update subset)
- Settings.vue: port auto-update UI into master's section-based layout
  - Place auto-update config in the maintenance section
  - Merge imports, state, functions, and onMounted
  - Add settings-section__divider CSS rule

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Dr1985

Dr1985 commented Jun 16, 2026

Copy link
Copy Markdown
Author

Hi @Soulter,

Conflicts with master have been resolved and pushed.
The PR is now ready for review.

Whenever you have time, I'd appreciate it if you could:

  1. Approve the workflows (5 awaiting approval)
  2. Review the changes at your convenience

No rush — thanks for maintaining AstrBot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:updater The bug / feature is about astrbot updater system size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 自动更新

1 participant