feat: add auto-update with backup, rollback, and retention#8814
Conversation
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).
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In the Settings auto-update panel, the
backupRetentiontext field enforcesmin="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
checkForUpdatemethod inSettings.vueuses 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip" | ||
| core_zip_path = update_temp_dir / f"{update_token}-core.zip" | ||
|
|
||
| # 更新前自动备份 |
There was a problem hiding this comment.
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:
- Extracting rollback behavior into a dedicated method to keep
update_projectlinear. - Sharing common backup/import wiring in small helpers instead of repeating setup.
- 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_projectThen 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 excThis 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 VERSIONThen 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: |
There was a problem hiding this comment.
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}"
)
raiseThe 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.
There was a problem hiding this comment.
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.
| 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}") |
There was a problem hiding this comment.
在自动更新流程中,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}")| 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) |
There was a problem hiding this comment.
如果用户在 WebUI 中将检查间隔或备份保留天数清空或设置为无效值,配置中可能会保存为 0、null 或 NaN。这会导致 _version_check_loop 在 asyncio.sleep(0) 下无休止地循环(导致 100% CPU 占用),或者因为 TypeError 导致后台任务崩溃。\n\n建议在属性读取器中加入健壮的类型转换和有效值校验,提供安全的默认值。
| 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 |
| 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'); | ||
| } | ||
| }; |
There was a problem hiding this comment.
在保存自动更新配置时,如果输入框被清空,autoUpdateCheckInterval.value 或 autoUpdateBackupRetention.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');
}
};
| 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; | ||
| } | ||
| }; |
There was a problem hiding this comment.
在 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;
}
};
| 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}") | ||
| ) |
There was a problem hiding this comment.
在 update_now 中,使用 asyncio.create_task 在后台异步执行更新流程。但在 Python 的 asyncio 中,如果不对创建的 Task 对象保留强引用,该任务在执行完毕前可能会被垃圾回收器(Garbage Collector)提前销毁,导致更新流程意外中断。\n\n建议将返回的 Task 对象保存到实例属性中(例如 self._update_task),以确保其生命周期安全。
| 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}") | |
| ) |
| 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" |
There was a problem hiding this comment.
这里使用了复杂的 __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"|
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>
|
我修改了,请问还有什么要改动的地方吗? I have made changes on the issues you've mentioned. Is there anything more to be changed? |
- 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>
|
Hi @Soulter, Conflicts with master have been resolved and pushed. Whenever you have time, I'd appreciate it if you could:
No rush — thanks for maintaining AstrBot! |
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
How to Test
Prerequisites
Test 1: WebUI Settings Page
astrbot/ your password)Test 2: Chat Commands
/update status→ should show version, auto-update config, and available commands/update check→ should check for new version and report result/update auto on→ should enable auto-update ("Auto-update is now ENABLED")/update auto off→ should disable auto-update ("Auto-update is now DISABLED")/update(no subcommand) → should show help text with subcommand list/update nonsense→ should show "Unknown subcommand" with usageTest 3: Backup on Update (via WebUI)
data/backups/directory → should contain aastrbot_backup_*.zipfile (the pre-update backup)Test 4: Backup Cleanup
Test 5: Auto-Update Trigger (simulated)
auto_update.enabledisfalseby default indata/cmd_config.jsontruemanually, or toggle in SettingsTest 6: Rollback Simulation
/update now invalid-version-tagTest Results
Environment
Unit Tests
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.mdIntegration Tests
27 failures are all pre-existing Windows platform issues (hardcoded
C:\Users\adminpaths, symlink permissions,\r\nline endings, file URI resolution). Detailed analysis:juboxiang to do/failing-tests-windows.mdDashboard Build
Lint
Architecture Notes
Auto-Update Flow
Reused Existing Components
AstrBotUpdator(astrbot/core/updator.py): version check, download, applyAstrBotExporter(astrbot/core/backup/exporter.py): full data backupAstrBotImporter(astrbot/core/backup/importer.py): data restoreUpdateService(astrbot/dashboard/services/update_service.py): update orchestrationCronJobManagerpattern: async background task schedulingDesign Decisions
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.Why backup only data, not code? The
AstrBotExporterbacks 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.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).
uv tool users: Already guarded by
AstrBotUpdator—ASTRBOT_CLIenv var check prevents update, directs touv 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).
Screenshots or Test Results / 运行截图或测试结果
before the changes, there are no auto-update in the settings page.

after the changes, there is auto-update in the settings page.



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.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.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:
Enhancements: