mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 18:20:16 +08:00
Compare commits
2 Commits
dev
...
v4.25.6-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ea91884c | ||
|
|
598a739bab |
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -71,6 +71,15 @@ jobs:
|
||||
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Build core package
|
||||
shell: bash
|
||||
run: |
|
||||
git archive \
|
||||
--format=zip \
|
||||
--prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \
|
||||
--output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \
|
||||
HEAD
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -78,11 +87,12 @@ jobs:
|
||||
if-no-files-found: error
|
||||
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
|
||||
|
||||
- name: Upload dashboard package to Cloudflare R2
|
||||
- name: Upload release packages to Cloudflare R2
|
||||
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
|
||||
env:
|
||||
R2_BUCKET_NAME: "astrbot"
|
||||
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||
DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip"
|
||||
CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip"
|
||||
VERSION_TAG: ${{ steps.tag.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -98,11 +108,18 @@ jobs:
|
||||
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
|
||||
rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}"
|
||||
rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
|
||||
cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}"
|
||||
rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
|
||||
cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip"
|
||||
rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress
|
||||
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.25.5"
|
||||
VERSION = "4.25.6-rc.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.io import ensure_dir
|
||||
|
||||
from .zip_updator import ReleaseInfo, RepoZipUpdator
|
||||
|
||||
@@ -21,6 +24,30 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
super().__init__(repo_mirror, verify=verify)
|
||||
self.MAIN_PATH = get_astrbot_path()
|
||||
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
|
||||
self.CORE_PACKAGE_BASE_URL = (
|
||||
"https://astrbot-registry.soulter.top/download/astrbot-core"
|
||||
)
|
||||
|
||||
def _build_core_package_url(self, version: str | None) -> str | None:
|
||||
"""Build the hosted core package URL for a release tag.
|
||||
|
||||
Args:
|
||||
version: Release tag, such as ``v4.26.6``.
|
||||
|
||||
Returns:
|
||||
Public package URL, or None when hosted package download is disabled.
|
||||
"""
|
||||
|
||||
if not version or not str(version).startswith("v"):
|
||||
return None
|
||||
|
||||
base_url = os.environ.get(
|
||||
"ASTRBOT_CORE_PACKAGE_BASE_URL",
|
||||
self.CORE_PACKAGE_BASE_URL,
|
||||
).strip()
|
||||
if not base_url:
|
||||
return None
|
||||
return f"{base_url.rstrip('/')}/{version}/source.zip"
|
||||
|
||||
def terminate_child_processes(self) -> None:
|
||||
"""终止当前进程的所有子进程
|
||||
@@ -151,6 +178,41 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
proxy="",
|
||||
progress_callback=None,
|
||||
) -> None:
|
||||
zip_path = await self.download_update_package(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
self.apply_update_package(zip_path)
|
||||
|
||||
if reboot:
|
||||
self._reboot()
|
||||
|
||||
async def download_update_package(
|
||||
self,
|
||||
latest=True,
|
||||
version=None,
|
||||
proxy="",
|
||||
path: str | Path = "temp.zip",
|
||||
progress_callback=None,
|
||||
) -> Path:
|
||||
"""Download an AstrBot core update package without applying it.
|
||||
|
||||
Args:
|
||||
latest: Whether to download the latest release.
|
||||
version: Specific release tag or commit hash to download.
|
||||
proxy: Optional GitHub proxy prefix.
|
||||
path: Destination zip path.
|
||||
progress_callback: Optional callback for download progress payloads.
|
||||
|
||||
Returns:
|
||||
Path to the downloaded update package.
|
||||
|
||||
Raises:
|
||||
Exception: If update metadata cannot resolve a package URL.
|
||||
"""
|
||||
|
||||
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
|
||||
file_url = None
|
||||
|
||||
@@ -159,15 +221,18 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
|
||||
) # 避免版本管理混乱
|
||||
|
||||
target_version = None
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
if self.compare_version(VERSION, latest_version) >= 0:
|
||||
raise Exception("当前已经是最新版本。")
|
||||
target_version = latest_version
|
||||
file_url = update_data[0]["zipball_url"]
|
||||
elif str(version).startswith("v"):
|
||||
# 更新到指定版本
|
||||
for data in update_data:
|
||||
if data["tag_name"] == version:
|
||||
target_version = data["tag_name"]
|
||||
file_url = data["zipball_url"]
|
||||
if not file_url:
|
||||
raise Exception(f"未找到版本号为 {version} 的更新文件。")
|
||||
@@ -181,16 +246,49 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
proxy = proxy.removesuffix("/")
|
||||
file_url = f"{proxy}/{file_url}"
|
||||
|
||||
try:
|
||||
await self._download_file(
|
||||
file_url,
|
||||
"temp.zip",
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
|
||||
self.unzip_file("temp.zip", self.MAIN_PATH)
|
||||
except BaseException as e:
|
||||
raise e
|
||||
zip_path = Path(path)
|
||||
ensure_dir(zip_path.parent)
|
||||
hosted_package_url = self._build_core_package_url(target_version)
|
||||
if hosted_package_url:
|
||||
try:
|
||||
logger.info(
|
||||
f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}"
|
||||
)
|
||||
await self._download_file(
|
||||
hosted_package_url,
|
||||
str(zip_path),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise RuntimeError(
|
||||
"Downloaded hosted package is not a valid ZIP file"
|
||||
)
|
||||
return zip_path
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"从托管存储下载 AstrBot Core 更新包失败: {exc},"
|
||||
"将回退到当前更新源。"
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._reboot()
|
||||
await self._download_file(
|
||||
file_url,
|
||||
str(zip_path),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
return zip_path
|
||||
|
||||
def apply_update_package(self, zip_path: str | Path) -> None:
|
||||
"""Apply a previously downloaded AstrBot core update package.
|
||||
|
||||
Args:
|
||||
zip_path: Core update zip archive path.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
|
||||
Raises:
|
||||
Exception: If the archive cannot be extracted or applied.
|
||||
"""
|
||||
|
||||
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
|
||||
self.unzip_file(str(zip_path), self.MAIN_PATH)
|
||||
|
||||
@@ -398,12 +398,27 @@ async def download_dashboard(
|
||||
version: str | None = None,
|
||||
proxy: str | None = None,
|
||||
progress_callback=None,
|
||||
extract: bool = True,
|
||||
) -> None:
|
||||
"""下载管理面板文件"""
|
||||
"""Download dashboard assets and optionally extract them.
|
||||
|
||||
Args:
|
||||
path: Destination zip path. Defaults to the AstrBot data directory.
|
||||
extract_path: Directory where assets should be extracted.
|
||||
latest: Whether to download the latest dashboard build.
|
||||
version: Specific release tag or commit hash to download.
|
||||
proxy: Optional download proxy prefix.
|
||||
progress_callback: Optional callback for download progress payloads.
|
||||
extract: Whether to extract the archive after download.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
if path is None:
|
||||
zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip"
|
||||
else:
|
||||
zip_path = Path(path).absolute()
|
||||
ensure_dir(zip_path.parent)
|
||||
|
||||
if latest or len(str(version)) != 40:
|
||||
ver_name = "latest" if latest else version
|
||||
@@ -456,5 +471,28 @@ async def download_dashboard(
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if extract:
|
||||
extract_dashboard(zip_path, extract_path)
|
||||
|
||||
|
||||
def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") -> None:
|
||||
"""Extract a downloaded dashboard archive.
|
||||
|
||||
Args:
|
||||
zip_path: Dashboard zip archive path.
|
||||
extract_path: Directory where the archive contents should be extracted.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
|
||||
extract_root = Path(extract_path).resolve()
|
||||
ensure_dir(extract_root)
|
||||
with zipfile.ZipFile(zip_path, "r") as z:
|
||||
z.extractall(extract_path)
|
||||
for member in z.infolist():
|
||||
target_path = (extract_root / member.filename).resolve()
|
||||
if not target_path.is_relative_to(extract_root):
|
||||
raise ValueError(
|
||||
f"Unsafe dashboard archive path: {member.filename}",
|
||||
)
|
||||
z.extract(member, extract_root)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from quart import request
|
||||
|
||||
@@ -8,7 +11,15 @@ from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_system_tmp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import (
|
||||
download_dashboard,
|
||||
extract_dashboard,
|
||||
get_dashboard_version,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -35,6 +46,7 @@ class UpdateRoute(Route):
|
||||
self.astrbot_updator = astrbot_updator
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.update_progress: dict[str, dict] = {}
|
||||
self._update_tasks: dict[str, asyncio.Task] = {}
|
||||
self.register_routes()
|
||||
|
||||
def _init_update_progress(self, progress_id: str, version: str) -> None:
|
||||
@@ -198,7 +210,62 @@ class UpdateRoute(Route):
|
||||
if proxy:
|
||||
proxy = proxy.removesuffix("/")
|
||||
|
||||
existing_task = self._update_tasks.get(progress_id)
|
||||
if existing_task and not existing_task.done():
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{"id": progress_id, "status": "running"},
|
||||
"更新任务正在进行中。",
|
||||
)
|
||||
.__dict__,
|
||||
200,
|
||||
CLEAR_SITE_DATA_HEADERS,
|
||||
)
|
||||
|
||||
self._init_update_progress(progress_id, version)
|
||||
task = asyncio.create_task(
|
||||
self._run_update_project(progress_id, version, latest, reboot, proxy),
|
||||
)
|
||||
self._update_tasks[progress_id] = task
|
||||
task.add_done_callback(lambda _task: self._update_tasks.pop(progress_id, None))
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{"id": progress_id, "status": "running"},
|
||||
"更新任务已开始。",
|
||||
)
|
||||
.__dict__,
|
||||
200,
|
||||
CLEAR_SITE_DATA_HEADERS,
|
||||
)
|
||||
|
||||
async def _run_update_project(
|
||||
self,
|
||||
progress_id: str,
|
||||
version: str,
|
||||
latest: bool,
|
||||
reboot: bool,
|
||||
proxy: str | None,
|
||||
) -> None:
|
||||
"""Run an update task outside the request lifecycle.
|
||||
|
||||
Args:
|
||||
progress_id: Progress record id reported to the frontend.
|
||||
version: Target version without the latest sentinel.
|
||||
latest: Whether to install the latest release.
|
||||
reboot: Whether to restart AstrBot after applying files.
|
||||
proxy: Optional GitHub proxy URL.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
|
||||
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
|
||||
update_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
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"
|
||||
try:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
@@ -208,15 +275,17 @@ class UpdateRoute(Route):
|
||||
0,
|
||||
)
|
||||
await download_dashboard(
|
||||
path=str(dashboard_zip_path),
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy,
|
||||
proxy=proxy or "",
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
extract=False,
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
@@ -233,16 +302,19 @@ class UpdateRoute(Route):
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
await self.astrbot_updator.update(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
45,
|
||||
),
|
||||
core_zip_path = Path(
|
||||
await self.astrbot_updator.download_update_package(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy or "",
|
||||
path=core_zip_path,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
45,
|
||||
),
|
||||
)
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
@@ -252,6 +324,50 @@ class UpdateRoute(Route):
|
||||
90,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"running",
|
||||
"下载完成,正在校验更新包...",
|
||||
90,
|
||||
)
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
corrupt_member = archive.testzip()
|
||||
if corrupt_member:
|
||||
raise RuntimeError(f"更新包校验失败: {corrupt_member}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"verify",
|
||||
"done",
|
||||
"更新包校验完成。",
|
||||
91,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"running",
|
||||
"下载完成,正在应用更新...",
|
||||
91,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.astrbot_updator.apply_update_package,
|
||||
core_zip_path,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
extract_dashboard,
|
||||
dashboard_zip_path,
|
||||
Path(get_astrbot_data_path()),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"apply",
|
||||
"done",
|
||||
"更新文件应用完成。",
|
||||
92,
|
||||
)
|
||||
|
||||
# pip 更新依赖
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
@@ -290,12 +406,7 @@ class UpdateRoute(Route):
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
ret = (
|
||||
Response()
|
||||
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
|
||||
.__dict__
|
||||
)
|
||||
return ret, 200, CLEAR_SITE_DATA_HEADERS
|
||||
return
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
@@ -304,12 +415,14 @@ class UpdateRoute(Route):
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
ret = (
|
||||
Response()
|
||||
.ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。")
|
||||
.__dict__
|
||||
except asyncio.CancelledError:
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "更新任务已取消。",
|
||||
},
|
||||
)
|
||||
return ret, 200, CLEAR_SITE_DATA_HEADERS
|
||||
logger.warning(f"Update task was cancelled: {progress_id}")
|
||||
except Exception as e:
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
@@ -318,7 +431,13 @@ class UpdateRoute(Route):
|
||||
},
|
||||
)
|
||||
logger.error(f"/api/update_project: {traceback.format_exc()}")
|
||||
return Response().error(e.__str__()).__dict__
|
||||
finally:
|
||||
for zip_path in (dashboard_zip_path, core_zip_path):
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as cleanup_exc:
|
||||
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
|
||||
|
||||
async def update_dashboard(self):
|
||||
try:
|
||||
|
||||
@@ -55,6 +55,7 @@ let showAdvancedUpdateSettings = ref(false);
|
||||
let restartWaiting = ref(false);
|
||||
let restartStartTime = ref<number | string | null>(null);
|
||||
let restartPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const RESTART_START_TIME_POLL_INTERVAL_MS = 2000;
|
||||
type DownloadStageStatus = "pending" | "running" | "done" | "error";
|
||||
type DownloadStage = {
|
||||
status: DownloadStageStatus;
|
||||
@@ -569,20 +570,25 @@ async function fetchAstrBotStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
function waitForAstrBotRestart(initialStartTime: number | string | null) {
|
||||
if (restartWaiting.value) {
|
||||
function waitForAstrBotRestart(
|
||||
initialStartTime: number | string | null,
|
||||
showWaiting = true,
|
||||
) {
|
||||
if (showWaiting && !restartWaiting.value) {
|
||||
restartWaiting.value = true;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
stage: "restart",
|
||||
status: "success",
|
||||
message: t("core.header.updateDialog.progress.restarting"),
|
||||
overall_percent: 100,
|
||||
};
|
||||
}
|
||||
if (restartPollTimer) {
|
||||
return;
|
||||
}
|
||||
stopRestartPolling();
|
||||
restartWaiting.value = true;
|
||||
|
||||
restartStartTime.value = initialStartTime;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
stage: "restart",
|
||||
status: "success",
|
||||
message: t("core.header.updateDialog.progress.restarting"),
|
||||
overall_percent: 100,
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
@@ -601,9 +607,10 @@ function waitForAstrBotRestart(initialStartTime: number | string | null) {
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
restartPollTimer = setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}, RESTART_START_TIME_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function applyUpdateProgress(payload: UpdateProgress) {
|
||||
@@ -616,8 +623,15 @@ function applyUpdateProgress(payload: UpdateProgress) {
|
||||
},
|
||||
};
|
||||
if (payload.status === "success" || payload.status === "error") {
|
||||
installLoading.value = false;
|
||||
stopUpdateProgressPolling();
|
||||
}
|
||||
if (payload.status === "error") {
|
||||
stopRestartPolling();
|
||||
}
|
||||
if (payload.stage === "restart") {
|
||||
waitForAstrBotRestart(restartStartTime.value);
|
||||
}
|
||||
if (payload.status === "success") {
|
||||
waitForAstrBotRestart(restartStartTime.value);
|
||||
}
|
||||
@@ -663,6 +677,7 @@ async function switchVersion(targetVersion: string) {
|
||||
initialStartTime = commonStore.getStartTime();
|
||||
}
|
||||
restartStartTime.value = initialStartTime;
|
||||
waitForAstrBotRestart(initialStartTime, false);
|
||||
startUpdateProgressPolling(progressId);
|
||||
|
||||
axios
|
||||
@@ -673,20 +688,27 @@ async function switchVersion(targetVersion: string) {
|
||||
})
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
status:
|
||||
res.data.status === "ok" ? "success" : updateProgress.value.status,
|
||||
message: res.data.message,
|
||||
overall_percent:
|
||||
res.data.status === "ok" ? 100 : updateProgress.value.overall_percent,
|
||||
};
|
||||
if (res.data.status == "ok") {
|
||||
waitForAstrBotRestart(initialStartTime);
|
||||
if (res.data.status === "error") {
|
||||
stopUpdateProgressPolling();
|
||||
installLoading.value = false;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
status: "error",
|
||||
message:
|
||||
res.data.message || t("core.header.updateDialog.progress.failed"),
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
stopUpdateProgressPolling();
|
||||
if (!err?.response && restartPollTimer) {
|
||||
waitForAstrBotRestart(restartStartTime.value);
|
||||
updateStatus.value = t("core.header.updateDialog.progress.restarting");
|
||||
return;
|
||||
}
|
||||
stopRestartPolling();
|
||||
installLoading.value = false;
|
||||
updateStatus.value = err;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
@@ -696,10 +718,6 @@ async function switchVersion(targetVersion: string) {
|
||||
err?.message ||
|
||||
t("core.header.updateDialog.progress.failed"),
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
installLoading.value = false;
|
||||
stopUpdateProgressPolling();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.25.5"
|
||||
version = "4.25.6-rc.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
@@ -120,7 +120,7 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
|
||||
allow-direct-references = true
|
||||
|
||||
# Include bundled dashboard dist even though it is not tracked by VCS.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
[tool.hatch.build]
|
||||
artifacts = ["astrbot/dashboard/dist/**"]
|
||||
|
||||
# Custom build hook: builds the Vue dashboard and copies dist into the package.
|
||||
|
||||
@@ -59,6 +59,34 @@ def _strip_query(url: str) -> str:
|
||||
return urlunsplit(("", "", parsed.path, "", parsed.fragment))
|
||||
|
||||
|
||||
async def _wait_for_update_progress(
|
||||
test_client,
|
||||
authenticated_header: dict,
|
||||
progress_id: str,
|
||||
) -> dict:
|
||||
"""Wait until an update task reaches a terminal status.
|
||||
|
||||
Args:
|
||||
test_client: Quart test client.
|
||||
authenticated_header: Headers for authenticated dashboard requests.
|
||||
progress_id: Update progress id to poll.
|
||||
|
||||
Returns:
|
||||
The update progress response payload.
|
||||
"""
|
||||
|
||||
for _ in range(100):
|
||||
response = await test_client.get(
|
||||
f"/api/update/progress?id={progress_id}",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
data = await response.get_json()
|
||||
if data["data"].get("status") in {"success", "error"}:
|
||||
return data
|
||||
await asyncio.sleep(0.01)
|
||||
pytest.fail(f"Update task did not finish: {progress_id}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch):
|
||||
plugin_root = (
|
||||
@@ -2463,40 +2491,77 @@ async def test_do_update(
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
tmp_path_factory,
|
||||
tmp_path,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
# Use a temporary path for the mock update to avoid side effects
|
||||
temp_release_dir = tmp_path_factory.mktemp("release")
|
||||
release_path = temp_release_dir / "astrbot"
|
||||
calls = []
|
||||
release_path = tmp_path / "astrbot"
|
||||
calls: list[str] = []
|
||||
|
||||
async def mock_update(*args, **kwargs):
|
||||
"""Mocks the update process by creating a directory in the temp path."""
|
||||
calls.append("core")
|
||||
async def mock_download_update_package(*args, **kwargs):
|
||||
"""Mock the core package download by writing a valid ZIP archive."""
|
||||
calls.append("download-core")
|
||||
callback = kwargs.get("progress_callback")
|
||||
if callback:
|
||||
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
|
||||
zip_path = Path(kwargs["path"])
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr("AstrBot-v3.4.0/README.md", "core")
|
||||
return zip_path
|
||||
|
||||
def mock_apply_update_package(zip_path):
|
||||
"""Mock applying the core package."""
|
||||
calls.append("apply-core")
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
os.makedirs(release_path, exist_ok=True)
|
||||
|
||||
async def mock_download_dashboard(*args, **kwargs):
|
||||
"""Mocks the dashboard download to prevent network access."""
|
||||
calls.append("dashboard")
|
||||
"""Mock the dashboard download by writing a valid ZIP archive."""
|
||||
calls.append("download-dashboard")
|
||||
callback = kwargs.get("progress_callback")
|
||||
if callback:
|
||||
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
|
||||
return
|
||||
zip_path = Path(kwargs["path"])
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr("dist/index.html", "dashboard")
|
||||
|
||||
def mock_extract_dashboard(zip_path, extract_path):
|
||||
"""Mock applying the dashboard package."""
|
||||
calls.append("apply-dashboard")
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
assert Path(extract_path) == tmp_path / "data"
|
||||
|
||||
async def mock_pip_install(*args, **kwargs):
|
||||
"""Mocks pip install to prevent actual installation."""
|
||||
calls.append("pip")
|
||||
return
|
||||
|
||||
monkeypatch.setattr(core_lifecycle_td.astrbot_updator, "update", mock_update)
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.astrbot_updator,
|
||||
"download_update_package",
|
||||
mock_download_update_package,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.astrbot_updator,
|
||||
"apply_update_package",
|
||||
mock_apply_update_package,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.download_dashboard",
|
||||
mock_download_dashboard,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.extract_dashboard",
|
||||
mock_extract_dashboard,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.get_astrbot_system_tmp_path",
|
||||
lambda: str(tmp_path / "tmp"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.get_astrbot_data_path",
|
||||
lambda: str(tmp_path / "data"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.pip_installer.install",
|
||||
mock_pip_install,
|
||||
@@ -2510,19 +2575,101 @@ async def test_do_update(
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert os.path.exists(release_path)
|
||||
assert calls[:2] == ["dashboard", "core"]
|
||||
assert data["data"]["id"] == "test-progress"
|
||||
assert data["data"]["status"] == "running"
|
||||
|
||||
progress_response = await test_client.get(
|
||||
"/api/update/progress?id=test-progress",
|
||||
headers=authenticated_header,
|
||||
progress_data = await _wait_for_update_progress(
|
||||
test_client,
|
||||
authenticated_header,
|
||||
"test-progress",
|
||||
)
|
||||
progress_data = await progress_response.get_json()
|
||||
assert os.path.exists(release_path)
|
||||
assert calls == [
|
||||
"download-dashboard",
|
||||
"download-core",
|
||||
"apply-core",
|
||||
"apply-dashboard",
|
||||
"pip",
|
||||
]
|
||||
assert progress_data["status"] == "ok"
|
||||
assert progress_data["data"]["status"] == "success"
|
||||
assert progress_data["data"]["overall_percent"] == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_do_update_does_not_apply_files_when_core_download_fails(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
calls: list[str] = []
|
||||
|
||||
async def mock_download_dashboard(*args, **kwargs):
|
||||
"""Mock the dashboard download by writing a valid ZIP archive."""
|
||||
calls.append("download-dashboard")
|
||||
zip_path = Path(kwargs["path"])
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr("dist/index.html", "dashboard")
|
||||
|
||||
async def mock_download_update_package(*args, **kwargs):
|
||||
"""Mock a core package download failure."""
|
||||
calls.append("download-core")
|
||||
raise RuntimeError("core download failed")
|
||||
|
||||
def fail_apply_update_package(*args, **kwargs):
|
||||
"""Ensure core files are not applied after a download failure."""
|
||||
calls.append("apply-core")
|
||||
raise AssertionError("core package should not be applied")
|
||||
|
||||
def fail_extract_dashboard(*args, **kwargs):
|
||||
"""Ensure dashboard files are not applied after a download failure."""
|
||||
calls.append("apply-dashboard")
|
||||
raise AssertionError("dashboard package should not be applied")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.get_astrbot_system_tmp_path",
|
||||
lambda: str(tmp_path / "tmp"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.download_dashboard",
|
||||
mock_download_dashboard,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.update.extract_dashboard",
|
||||
fail_extract_dashboard,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.astrbot_updator,
|
||||
"download_update_package",
|
||||
mock_download_update_package,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.astrbot_updator,
|
||||
"apply_update_package",
|
||||
fail_apply_update_package,
|
||||
)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/update/do",
|
||||
headers=authenticated_header,
|
||||
json={"version": "v3.4.0", "reboot": False, "progress_id": "atomic-fail"},
|
||||
)
|
||||
data = await response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "ok"
|
||||
progress_data = await _wait_for_update_progress(
|
||||
test_client,
|
||||
authenticated_header,
|
||||
"atomic-fail",
|
||||
)
|
||||
assert progress_data["data"]["status"] == "error"
|
||||
assert calls == ["download-dashboard", "download-core"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_pip_package_returns_pip_install_error_message(
|
||||
app: Quart,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import ntpath
|
||||
import posixpath
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from astrbot.core.star.updator import PluginUpdator
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.zip_updator import RepoZipUpdator
|
||||
|
||||
|
||||
@@ -286,6 +289,144 @@ async def test_plugin_updator_install_prefers_download_url(
|
||||
assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_prefers_hosted_core_package(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
|
||||
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
|
||||
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
|
||||
|
||||
updator = AstrBotUpdator()
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AstrBot-v99.0.0/README.md", "hosted-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(updator, "_download_file", fake_download_file)
|
||||
|
||||
zip_path = await updator.download_update_package(
|
||||
latest=False,
|
||||
version="v99.0.0",
|
||||
path=tmp_path / "core.zip",
|
||||
)
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
assert calls == ["https://cdn.example/core/v99.0.0/source.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_falls_back_when_hosted_core_package_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
|
||||
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
|
||||
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
|
||||
|
||||
updator = AstrBotUpdator()
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
|
||||
raise RuntimeError("404")
|
||||
Path(path).write_bytes(b"github-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(updator, "_download_file", fake_download_file)
|
||||
|
||||
zip_path = await updator.download_update_package(
|
||||
latest=False,
|
||||
version="v99.0.0",
|
||||
path=tmp_path / "core.zip",
|
||||
)
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zip_path.read_bytes() == b"github-core"
|
||||
assert calls == [
|
||||
"https://cdn.example/core/v99.0.0/source.zip",
|
||||
"https://github.example/archive.zip",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_falls_back_when_hosted_core_package_is_not_zip(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
|
||||
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
|
||||
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
|
||||
|
||||
updator = AstrBotUpdator()
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v99.0.0",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "hosted core package",
|
||||
"tag_name": "v99.0.0",
|
||||
"zipball_url": "https://github.example/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
|
||||
calls.append(url)
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
|
||||
Path(path).write_bytes(b"not a zip")
|
||||
return
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("AstrBot-v99.0.0/README.md", "github-core")
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(updator, "_download_file", fake_download_file)
|
||||
|
||||
zip_path = await updator.download_update_package(
|
||||
latest=False,
|
||||
version="v99.0.0",
|
||||
path=tmp_path / "core.zip",
|
||||
)
|
||||
|
||||
assert zip_path == tmp_path / "core.zip"
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
assert calls == [
|
||||
"https://cdn.example/core/v99.0.0/source.zip",
|
||||
"https://github.example/archive.zip",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user