diff --git a/.github/workflows/install_script_test.yml b/.github/workflows/install_script_test.yml
new file mode 100644
index 00000000000..37d9468992c
--- /dev/null
+++ b/.github/workflows/install_script_test.yml
@@ -0,0 +1,54 @@
+name: Test install scripts
+
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - 'docs/public/install.ps1'
+ - 'docs/public/install.sh'
+ - 'tests/test_install_script.py'
+ - 'tests/install_script.Tests.ps1'
+ - '.github/workflows/install_script_test.yml'
+ pull_request:
+ paths:
+ - 'docs/public/install.ps1'
+ - 'docs/public/install.sh'
+ - 'tests/test_install_script.py'
+ - 'tests/install_script.Tests.ps1'
+ - '.github/workflows/install_script_test.yml'
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: Windows PowerShell compatibility
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Test Windows PowerShell 5.1
+ shell: powershell
+ run: .\tests\install_script.Tests.ps1
+
+ - name: Test PowerShell 7
+ shell: pwsh
+ run: .\tests\install_script.Tests.ps1
+
+ shell:
+ name: Bash installer (${{ matrix.os }})
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Test Bash installer
+ shell: bash
+ run: |
+ /bin/bash -n docs/public/install.sh
+ python3 tests/test_install_script.py
diff --git a/README.md b/README.md
index bfdc06b7aea..f6d5d01eca0 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to down
**More deployment methods**
-If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
+If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html), [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html), [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html), or [Source Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html).
## ❤️ Sponsors
diff --git a/README_zh-TW.md b/README_zh-TW.md
index 6b6cd31e422..5794093b489 100644
--- a/README_zh-TW.md
+++ b/README_zh-TW.md
@@ -101,7 +101,7 @@ uv tool upgrade astrbot --python 3.12
**更多部署方式**
-若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
+若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) 或 [原始碼部署](https://docs.astrbot.app/deploy/astrbot/cli.html)。
## ❤️ 贊助商
diff --git a/README_zh.md b/README_zh.md
index b38385512dc..3422bd7b76b 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -102,7 +102,7 @@ uv tool upgrade astrbot --python 3.12
**更多部署方式**
-若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
+若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) 和 [源码部署](https://docs.astrbot.app/deploy/astrbot/cli.html)。
## ❤️ 赞助商
diff --git a/astrbot/core/utils/trace_store.py b/astrbot/core/utils/trace_store.py
index 63eb2bfec00..9a0be6e9489 100644
--- a/astrbot/core/utils/trace_store.py
+++ b/astrbot/core/utils/trace_store.py
@@ -257,7 +257,8 @@ def _write(self, connection: sqlite3.Connection, batch: list[dict]) -> None:
has_models=MAX(traces.has_models, excluded.has_models),
has_plugins=MAX(traces.has_plugins, excluded.has_plugins),
search=excluded.search, data=excluded.data, size_bytes=excluded.size_bytes
- WHERE excluded.updated_ns >= traces.updated_ns""",
+ WHERE excluded.updated_ns >= traces.updated_ns
+ AND (traces.state = 'running' OR excluded.state != 'running')""",
(
trace_id,
int(summary["start_time_unix_nano"]),
diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs
index b7d494a4a69..245521ad2a0 100644
--- a/docs/.vitepress/config.mjs
+++ b/docs/.vitepress/config.mjs
@@ -43,7 +43,7 @@ export default defineConfig({
base: "/deploy",
collapsed: false,
items: [
- { text: "包管理器部署", link: "/astrbot/package" },
+ { text: "脚本部署", link: "/astrbot/package" },
{ text: "雨云一键云部署", link: "/astrbot/rainyun" },
{ text: "桌面客户端部署", link: "/astrbot/desktop" },
{ text: "启动器一键部署", link: "/astrbot/launcher" },
@@ -51,7 +51,7 @@ export default defineConfig({
{ text: "Kubernetes 部署", link: "/astrbot/kubernetes" },
{ text: "宝塔面板部署", link: "/astrbot/btpanel" },
{ text: "1Panel 部署", link: "/astrbot/1panel" },
- { text: "手动部署", link: "/astrbot/cli" },
+ { text: "源码部署", link: "/astrbot/cli" },
{
text: "其他部署方式",
link: "/astrbot/other-deployments",
@@ -313,14 +313,14 @@ export default defineConfig({
base: "/en/deploy",
collapsed: false,
items: [
- { text: "Package Manager", link: "/astrbot/package" },
+ { text: "Script Deployment", link: "/astrbot/package" },
{ text: "Desktop Client", link: "/astrbot/desktop" },
{ text: "One-click Launcher", link: "/astrbot/launcher" },
{ text: "Docker", link: "/astrbot/docker" },
{ text: "Kubernetes", link: "/astrbot/kubernetes" },
{ text: "BT Panel", link: "/astrbot/btpanel" },
{ text: "1Panel", link: "/astrbot/1panel" },
- { text: "Manual", link: "/astrbot/cli" },
+ { text: "Source Deployment", link: "/astrbot/cli" },
{
text: "Other Deployments",
link: "/astrbot/other-deployments",
diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md
index c921f209d92..2e3e03e342e 100644
--- a/docs/en/deploy/astrbot/package.md
+++ b/docs/en/deploy/astrbot/package.md
@@ -1,24 +1,65 @@
-# Package Manager Deployment (uv)
+# Script Deployment
-Use `uv` to install and run AstrBot quickly.
+AstrBot provides installation scripts for Linux, macOS, WSL, and Windows. The scripts install `uv`, AstrBot, and Python 3.12.
-## Before You Start
+## Linux / macOS / WSL
-If `uv` is not installed, install it first by following the official guide:
-
+Interactive installation:
-`uv` supports Linux, Windows, and macOS.
+```bash
+curl -fsSL https://docs.astrbot.app/install.sh | bash
+```
+
+Unattended installation:
+
+```bash
+curl -fsSL https://docs.astrbot.app/install.sh -o install.sh
+bash install.sh --language en-US --download-source official --non-interactive
+```
+
+`--download-source` accepts `china`, `official`, or `custom`. The default language is English. If the source is omitted, Chinese uses the mainland China mirrors; English and Japanese use official sources.
+
+## Windows
+
+Run these commands in PowerShell 5.1 or PowerShell 7+.
+
+Interactive installation:
-## Important Notes
+```powershell
+irm https://docs.astrbot.app/install.ps1 | iex
+```
+
+Unattended installation:
+
+```powershell
+Invoke-WebRequest https://docs.astrbot.app/install.ps1 -OutFile install.ps1 -UseBasicParsing
+.\install.ps1 -Language en-US -DownloadSource official -NonInteractive
+```
+
+`-DownloadSource` accepts `china`, `official`, or `custom`. The default language is English. If the source is omitted, Chinese uses the mainland China mirrors; English and Japanese use official sources.
-> [!WARNING]
-> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, run `uv tool upgrade astrbot --python 3.12` from the command line.
+The mainland China option also configures the USTC mirror for Python 3.12 and retries with the official Python source on failure. Existing Python mirror environment variables or explicit options take priority.
-AstrBot requires Python 3.12 or later. Use `--python 3.12` to ensure that `uv` creates the tool environment with Python 3.12; if Python downloads are enabled, `uv` will download Python 3.12 automatically when it is missing.
+## After Installation
-## Install and Start
+The installer runs `uv tool update-shell`. Reload the environment for your shell:
```bash
-uv tool install astrbot --python 3.12
-astrbot
+# Bash
+source ~/.bashrc
+# Zsh
+source ~/.zshrc
+# Fish
+source ~/.config/fish/config.fish
+# PowerShell
+. $PROFILE
```
+
+Then run these commands manually:
+
+```bash
+astrbot init
+astrbot run
+```
+
+> AstrBot installed by script does not support upgrades through the WebUI. Run `uv tool upgrade astrbot --python 3.12` to update it.
diff --git a/docs/public/install.ps1 b/docs/public/install.ps1
index 0998d2f6a0a..6e65377e474 100644
--- a/docs/public/install.ps1
+++ b/docs/public/install.ps1
@@ -1,10 +1,225 @@
-# deploy-cli.ps1 - Install astrbot with uv on Windows PowerShell.
+#Requires -Version 5.1
-#Requires -Version 7.0
+# Install AstrBot with uv on Windows PowerShell.
+[CmdletBinding()]
+param(
+ # Invoke-Expression validates omitted string parameters as empty values.
+ [ValidateSet('', 'zh-CN', 'en-US', 'ja-JP')]
+ [string]$Language,
+ [ValidateSet('', 'china', 'official', 'custom')]
+ [string]$DownloadSource,
+ [string]$UvDownloadBaseUrl,
+ [string]$PypiIndexUrl,
+ [string]$PythonInstallMirror,
+ [switch]$NonInteractive
+)
$ErrorActionPreference = 'Stop'
+# Windows PowerShell 5.1 may otherwise negotiate an obsolete TLS version.
+[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+
$UseColor = [string]::IsNullOrEmpty($env:NO_COLOR) -and [Console]::IsOutputRedirected -eq $false
+$CanPrompt = -not $NonInteractive -and [Environment]::UserInteractive -and -not [Console]::IsInputRedirected
+$Indent = ' '
+
+# Keep the first-run experience predictable across machines. Users can still
+# choose Chinese or Japanese explicitly in the language menu.
+# Use JSON Unicode escapes so Windows PowerShell 5.1 can read this file
+# with any system encoding, while downloaded UTF-8 scripts also work without a BOM.
+$bootstrapText = ConvertFrom-Json @'
+{
+ "InstallerTitle": "\ud83c\udf0c AstrBot Installer",
+ "LanguagePrompt": "Select language / \u8bf7\u9009\u62e9\u8bed\u8a00 / \u8a00\u8a9e\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044",
+ "Chinese": "\u7b80\u4f53\u4e2d\u6587",
+ "English": "English",
+ "Japanese": "\u65e5\u672c\u8a9e",
+ "NextStepsEnglish": "Next, run these commands:",
+ "NextStepsChinese": "\u63a5\u4e0b\u6765\u53ef\u4ee5\u4f9d\u6b21\u6267\u884c\u4ee5\u4e0b\u6307\u4ee4\u6765\u521d\u59cb\u5316\u548c\u542f\u52a8 AstrBot\uff1a",
+ "NextStepsJapanese": "\u6b21\u306b\u3001\u4ee5\u4e0b\u3092\u9806\u756a\u306b\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\uff1a",
+ "PythonFallbackEnglish": "Python mirror failed; retrying with the official source...",
+ "PythonFallbackChinese": "Python \u955c\u50cf\u4e0b\u8f7d\u5931\u8d25\uff0c\u6b63\u5728\u5207\u6362\u5230\u5b98\u65b9\u6e90\u91cd\u8bd5\u2026",
+ "PythonFallbackJapanese": "Python \u30df\u30e9\u30fc\u306b\u5931\u6557\u3057\u305f\u305f\u3081\u3001\u516c\u5f0f\u30bd\u30fc\u30b9\u3067\u518d\u8a66\u884c\u3057\u3066\u3044\u307e\u3059\u2026"
+}
+'@
+
+$defaultLanguage = 'en-US'
+
+if (-not $Language) {
+ $Language = $defaultLanguage
+ if ($CanPrompt) {
+ $languages = @('en-US', 'zh-CN', 'ja-JP')
+ $defaultChoice = [array]::IndexOf($languages, $Language) + 1
+ Write-Host "`n$Indent$($bootstrapText.InstallerTitle)" -ForegroundColor Cyan
+ Write-Host "$Indent-----------------------" -ForegroundColor DarkGray
+ Write-Host "`n$Indent$($bootstrapText.LanguagePrompt)"
+ Write-Host "$Indent 1. $($bootstrapText.English)"
+ Write-Host "$Indent 2. $($bootstrapText.Chinese)"
+ Write-Host "$Indent 3. $($bootstrapText.Japanese)"
+ do {
+ $choice = Read-Host "${Indent}1-3 [$defaultChoice]"
+ } until ($choice -eq '' -or $choice -in @('1', '2', '3'))
+ if ($choice) { $Language = $languages[[int]$choice - 1] }
+ }
+}
+
+$messages = ConvertFrom-Json @'
+{
+ "zh-CN": {
+ "ChooseSource": "\u8bf7\u9009\u62e9\u4e0b\u8f7d\u6e90",
+ "China": "\u4e2d\u56fd\u5927\u9646\u52a0\u901f\u4e0b\u8f7d\u955c\u50cf\u6e90",
+ "Official": "\u5b98\u65b9\u6e90",
+ "Custom": "\u81ea\u5b9a\u4e49",
+ "Choice": "\u8f93\u5165\u7f16\u53f7\uff0c\u6309\u56de\u8f66\u4f7f\u7528\u9ed8\u8ba4\u9009\u9879",
+ "InvalidChoice": "\u8bf7\u8f93\u5165 1\u30012 \u6216 3\u3002",
+ "UvUrl": "uv ZIP \u4e0b\u8f7d\u76ee\u5f55 URL\uff08\u4e0d\u542b\u6587\u4ef6\u540d\uff09",
+ "PypiUrl": "PyPI simple \u7d22\u5f15 URL",
+ "CustomRequired": "\u81ea\u5b9a\u4e49\u6e90\u9700\u8981\u540c\u65f6\u63d0\u4f9b UvDownloadBaseUrl \u548c PypiIndexUrl\u3002",
+ "InvalidUrl": "\u4e0b\u8f7d\u6e90\u5fc5\u987b\u662f\u6709\u6548\u7684 HTTPS URL\uff1a{0}",
+ "WindowsOnly": "\u8bf7\u5728 Windows \u4e0a\u8fd0\u884c\u6b64 PowerShell \u811a\u672c\uff1bLinux/macOS \u8bf7\u4f7f\u7528 install.sh\u3002",
+ "Source": "\u4e0b\u8f7d\u6e90\uff1a{0}",
+ "UvMirror": "uv \u4e0b\u8f7d\u76ee\u5f55\uff1a{0}",
+ "Pypi": "PyPI \u7d22\u5f15\uff1a{0}",
+ "PythonMirror": "Python \u4e0b\u8f7d\u955c\u50cf\uff1a{0}",
+ "PythonDefault": "Python \u4e0b\u8f7d\u6cbf\u7528 uv \u7684\u914d\u7f6e\uff1b\u5982\u9700\u52a0\u901f\uff0c\u53ef\u901a\u8fc7 -PythonInstallMirror \u6307\u5b9a\u955c\u50cf\uff08PyPI \u955c\u50cf\u4e0d\u5305\u542b Python \u89e3\u91ca\u5668\uff09\u3002",
+ "InstallingUv": "\u672a\u627e\u5230 uv\uff0c\u6b63\u5728\u5b89\u88c5\u2026",
+ "TryingMirror": "\u6b63\u5728\u5c1d\u8bd5 uv \u955c\u50cf\uff1a{0}",
+ "MirrorFailed": "uv \u955c\u50cf\u4e0b\u8f7d\u5931\u8d25\uff1a{0}",
+ "OfficialFallback": "\u6b63\u5728\u5c1d\u8bd5 uv \u5b98\u65b9\u5b89\u88c5\u5668\u2026",
+ "UnsupportedArch": "\u4e0d\u652f\u6301\u7684 Windows \u67b6\u7784\uff1a{0}",
+ "InvalidChecksum": "uv \u538b\u7f29\u5305 SHA256 \u6821\u9a8c\u5931\u8d25\u3002",
+ "MissingBinaries": "uv \u538b\u7f29\u5305\u4e2d\u7f3a\u5c11 uv.exe \u6216 uvx.exe\u3002",
+ "UvFailed": "uv \u5b89\u88c5\u5931\u8d25\u3002\u8bf7\u53c2\u8003 https://docs.astral.sh/uv/getting-started/installation/",
+ "InstallingAstrBot": "\u6b63\u5728\u4f7f\u7528 Python 3.12 \u5b89\u88c5 AstrBot\u2026",
+ "AstrBotFailed": "AstrBot \u5b89\u88c5\u5931\u8d25\uff08\u9000\u51fa\u7801 {0}\uff09\u3002\u8bf7\u68c0\u67e5\u4e0a\u65b9\u7684\u4e0b\u8f7d\u6216\u4f9d\u8d56\u9519\u8bef\u3002",
+ "Installed": "AstrBot \u5b89\u88c5\u5b8c\u6210\u3002",
+ "ReloadShell": "\u8bf7\u6839\u636e\u4f60\u4f7f\u7528\u7684\u7ec8\u7aef\u91cd\u65b0\u52a0\u8f7d\u73af\u5883\uff1a",
+ "RunCommands": "\u91cd\u65b0\u52a0\u8f7d\u540e\uff0c\u8bf7\u624b\u52a8\u8fd0\u884c\uff1a"
+ },
+ "en-US": {
+ "ChooseSource": "Select a download source",
+ "China": "Mainland China accelerated download mirrors",
+ "Official": "Official sources",
+ "Custom": "Custom",
+ "Choice": "Enter a number, or press Enter to use the default",
+ "InvalidChoice": "Please enter 1, 2, or 3.",
+ "UvUrl": "uv ZIP download directory URL (without the filename)",
+ "PypiUrl": "PyPI simple index URL",
+ "CustomRequired": "Custom sources require both UvDownloadBaseUrl and PypiIndexUrl.",
+ "InvalidUrl": "Download sources must be valid HTTPS URLs: {0}",
+ "WindowsOnly": "Run this PowerShell script on Windows; use install.sh on Linux/macOS.",
+ "Source": "Download source: {0}",
+ "UvMirror": "uv download directory: {0}",
+ "Pypi": "PyPI index: {0}",
+ "PythonMirror": "Python download mirror: {0}",
+ "PythonDefault": "Python downloads use the existing uv configuration. Set -PythonInstallMirror to use a mirror (PyPI mirrors do not include Python interpreters).",
+ "InstallingUv": "uv was not found. Installing uv...",
+ "TryingMirror": "Trying uv mirror: {0}",
+ "MirrorFailed": "uv mirror download failed: {0}",
+ "OfficialFallback": "Trying the official uv installer...",
+ "UnsupportedArch": "Unsupported Windows architecture: {0}",
+ "InvalidChecksum": "uv archive SHA256 verification failed.",
+ "MissingBinaries": "The uv archive is missing uv.exe or uvx.exe.",
+ "UvFailed": "Failed to install uv. See https://docs.astral.sh/uv/getting-started/installation/",
+ "InstallingAstrBot": "Installing AstrBot with Python 3.12...",
+ "AstrBotFailed": "AstrBot installation failed (exit code {0}). Check the download or dependency errors above.",
+ "Installed": "AstrBot has been installed.",
+ "ReloadShell": "Reload the environment for your shell:",
+ "RunCommands": "Then run these commands manually:"
+ },
+ "ja-JP": {
+ "ChooseSource": "\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u5143\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044",
+ "China": "\u4e2d\u56fd\u672c\u571f\u5411\u3051\u9ad8\u901f\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30df\u30e9\u30fc",
+ "Official": "\u516c\u5f0f\u30bd\u30fc\u30b9",
+ "Custom": "\u30ab\u30b9\u30bf\u30e0",
+ "Choice": "\u756a\u53f7\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002Enter \u3067\u65e2\u5b9a\u5024\u3092\u4f7f\u7528\u3057\u307e\u3059",
+ "InvalidChoice": "1\u30012\u30013 \u306e\u3044\u305a\u308c\u304b\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002",
+ "UvUrl": "uv ZIP \u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30c7\u30a3\u30ec\u30af\u30c8\u30ea URL\uff08\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u9664\u304f\uff09",
+ "PypiUrl": "PyPI simple \u30a4\u30f3\u30c7\u30c3\u30af\u30b9 URL",
+ "CustomRequired": "\u30ab\u30b9\u30bf\u30e0\u30bd\u30fc\u30b9\u306b\u306f UvDownloadBaseUrl \u3068 PypiIndexUrl \u306e\u4e21\u65b9\u304c\u5fc5\u8981\u3067\u3059\u3002",
+ "InvalidUrl": "\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u5143\u306b\u306f\u6709\u52b9\u306a HTTPS URL \u3092\u6307\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\uff1a{0}",
+ "WindowsOnly": "\u3053\u306e PowerShell \u30b9\u30af\u30ea\u30d7\u30c8\u306f Windows \u7528\u3067\u3059\u3002Linux/macOS \u3067\u306f install.sh \u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002",
+ "Source": "\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u5143\uff1a{0}",
+ "UvMirror": "uv \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30c7\u30a3\u30ec\u30af\u30c8\u30ea\uff1a{0}",
+ "Pypi": "PyPI \u30a4\u30f3\u30c7\u30c3\u30af\u30b9\uff1a{0}",
+ "PythonMirror": "Python \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u30df\u30e9\u30fc\uff1a{0}",
+ "PythonDefault": "Python \u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u306b\u306f\u65e2\u5b58\u306e uv \u8a2d\u5b9a\u3092\u4f7f\u7528\u3057\u307e\u3059\u3002\u30df\u30e9\u30fc\u3092\u4f7f\u3046\u306b\u306f -PythonInstallMirror \u3092\u6307\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\uff08PyPI \u30df\u30e9\u30fc\u306b Python \u672c\u4f53\u306f\u542b\u307e\u308c\u307e\u305b\u3093\uff09\u3002",
+ "InstallingUv": "uv \u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3057\u3066\u3044\u307e\u3059\u2026",
+ "TryingMirror": "uv \u30df\u30e9\u30fc\u3092\u8a66\u3057\u3066\u3044\u307e\u3059\uff1a{0}",
+ "MirrorFailed": "uv \u30df\u30e9\u30fc\u304b\u3089\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\uff1a{0}",
+ "OfficialFallback": "uv \u306e\u516c\u5f0f\u30a4\u30f3\u30b9\u30c8\u30fc\u30e9\u30fc\u3092\u8a66\u3057\u3066\u3044\u307e\u3059\u2026",
+ "UnsupportedArch": "\u672a\u5bfe\u5fdc\u306e Windows \u30a2\u30fc\u30ad\u30c6\u30af\u30c1\u30e3\uff1a{0}",
+ "InvalidChecksum": "uv \u30a2\u30fc\u30ab\u30a4\u30d6\u306e SHA256 \u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002",
+ "MissingBinaries": "uv \u30a2\u30fc\u30ab\u30a4\u30d6\u306b uv.exe \u307e\u305f\u306f uvx.exe \u304c\u3042\u308a\u307e\u305b\u3093\u3002",
+ "UvFailed": "uv \u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002https://docs.astral.sh/uv/getting-started/installation/ \u3092\u53c2\u7167\u3057\u3066\u304f\u3060\u3055\u3044\u3002",
+ "InstallingAstrBot": "Python 3.12 \u3067 AstrBot \u3092\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3057\u3066\u3044\u307e\u3059\u2026",
+ "AstrBotFailed": "AstrBot \u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306b\u5931\u6557\u3057\u307e\u3057\u305f\uff08\u7d42\u4e86\u30b3\u30fc\u30c9 {0}\uff09\u3002\u4e0a\u8a18\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u307e\u305f\u306f\u4f9d\u5b58\u95a2\u4fc2\u306e\u30a8\u30e9\u30fc\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002",
+ "Installed": "AstrBot \u306e\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u304c\u5b8c\u4e86\u3057\u307e\u3057\u305f\u3002",
+ "ReloadShell": "\u4f7f\u7528\u3059\u308b\u30b7\u30a7\u30eb\u306b\u5408\u308f\u305b\u3066\u74b0\u5883\u3092\u518d\u8aad\u307f\u8fbc\u307f\u3057\u3066\u304f\u3060\u3055\u3044\uff1a",
+ "RunCommands": "\u305d\u306e\u5f8c\u3001\u4ee5\u4e0b\u306e\u30b3\u30de\u30f3\u30c9\u3092\u624b\u52d5\u3067\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\uff1a"
+ }
+}
+'@
+$Text = $messages.$Language
+
+if (-not $DownloadSource) {
+ $DownloadSource = if ($Language -eq 'zh-CN') { 'china' } else { 'official' }
+ if ($CanPrompt) {
+ $defaultChoice = if ($Language -eq 'zh-CN') { '1' } else { '2' }
+ Write-Host "`n$Indent$($Text.ChooseSource)"
+ Write-Host "$Indent 1. $($Text.China)"
+ Write-Host "$Indent 2. $($Text.Official)"
+ Write-Host "$Indent 3. $($Text.Custom)"
+ do {
+ $choice = Read-Host "$Indent$($Text.Choice) [$defaultChoice]"
+ if ($choice -eq '') { $choice = $defaultChoice }
+ if ($choice -notin @('1', '2', '3')) { Write-Host "$Indent$($Text.InvalidChoice)" }
+ } until ($choice -in @('1', '2', '3'))
+ $DownloadSource = @('china', 'official', 'custom')[[int]$choice - 1]
+ }
+}
+
+$uvMirrorUrls = @()
+switch ($DownloadSource) {
+ 'china' {
+ # Use uv mirrors listed by MirrorZ; the release-directory layout is not GitHub's layout.
+ $uvMirrorUrls = @(
+ 'https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease'
+ )
+ $PypiIndexUrl = 'https://mirrors.cernet.edu.cn/pypi/web/simple/'
+ }
+ 'official' { $PypiIndexUrl = 'https://pypi.org/simple/' }
+ 'custom' {
+ if ($CanPrompt) {
+ if (-not $UvDownloadBaseUrl) { $UvDownloadBaseUrl = Read-Host "$Indent$($Text.UvUrl)" }
+ if (-not $PypiIndexUrl) { $PypiIndexUrl = Read-Host "$Indent$($Text.PypiUrl)" }
+ }
+ if (-not $UvDownloadBaseUrl -or -not $PypiIndexUrl) {
+ throw $Text.CustomRequired
+ }
+ $uvMirrorUrls = @($UvDownloadBaseUrl.TrimEnd('/'))
+ }
+}
+
+if (-not $PythonInstallMirror) {
+ if ($env:UV_PYTHON_INSTALL_MIRROR) {
+ $PythonInstallMirror = $env:UV_PYTHON_INSTALL_MIRROR
+ } elseif ($DownloadSource -eq 'china') {
+ $PythonInstallMirror = 'https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/'
+ }
+}
+
+foreach ($url in @($uvMirrorUrls) + @($PypiIndexUrl, $PythonInstallMirror)) {
+ if (-not $url) { continue }
+ $parsedUrl = $null
+ if (-not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$parsedUrl) -or
+ $parsedUrl.Scheme -ne 'https' -or -not $parsedUrl.Host -or $parsedUrl.UserInfo -or
+ $parsedUrl.Query -or $parsedUrl.Fragment) {
+ throw ($Text.InvalidUrl -f $url)
+ }
+}
+
+if ($env:OS -ne 'Windows_NT') { throw $Text.WindowsOnly }
function Write-Status {
param(
@@ -14,9 +229,9 @@ function Write-Status {
)
if ($UseColor) {
- Write-Host "$Prefix $Message" -ForegroundColor $Color
+ Write-Host "$Indent$Prefix $Message" -ForegroundColor $Color
} else {
- Write-Host "$Prefix $Message"
+ Write-Host "$Indent$Prefix $Message"
}
}
@@ -31,6 +246,45 @@ function Test-Command {
return $?
}
+function Test-ZipArchive {
+ param([string]$Path)
+
+ if (-not (Test-Path -LiteralPath $Path) -or (Get-Item -LiteralPath $Path).Length -lt 4) {
+ return $false
+ }
+
+ $stream = [System.IO.File]::OpenRead($Path)
+ try {
+ return $stream.ReadByte() -eq 0x50 -and
+ $stream.ReadByte() -eq 0x4B -and
+ $stream.ReadByte() -eq 0x03 -and
+ $stream.ReadByte() -eq 0x04
+ } finally {
+ $stream.Dispose()
+ }
+}
+
+function Invoke-UvToolInstall {
+ param([string[]]$ArgumentList)
+
+ $uvCommand = Get-Command 'uv' -ErrorAction Stop
+ if ($uvCommand.CommandType -ne 'Application') {
+ # This path also makes the installer straightforward to test without
+ # downloading packages or changing the developer machine.
+ & $uvCommand @ArgumentList
+ return $LASTEXITCODE
+ }
+
+ # PowerShell may otherwise give console programs their own window in some
+ # hosts. Explicitly keep uv and all of its installation output in this one.
+ # Do not inherit the current directory: it may have been removed while an
+ # existing PowerShell session was still located there.
+ $workingDirectory = [System.IO.Path]::GetTempPath()
+ $process = Start-Process -FilePath $uvCommand.Source -ArgumentList $ArgumentList `
+ -WorkingDirectory $workingDirectory -NoNewWindow -Wait -PassThru
+ return $process.ExitCode
+}
+
function Update-UvPath {
$candidateDirs = @()
@@ -43,6 +297,12 @@ function Update-UvPath {
if ($env:LOCALAPPDATA) {
$candidateDirs += Join-Path $env:LOCALAPPDATA "uv"
}
+ foreach ($dir in @($env:UV_INSTALL_DIR, $env:UV_UNMANAGED_INSTALL, $env:XDG_BIN_HOME)) {
+ if ($dir) { $candidateDirs += $dir }
+ }
+ if ($env:XDG_DATA_HOME) {
+ $candidateDirs += Join-Path $env:XDG_DATA_HOME '..\bin'
+ }
foreach ($dir in $candidateDirs) {
if ((Test-Path $dir) -and (($env:PATH -split ';') -notcontains $dir)) {
@@ -52,22 +312,117 @@ function Update-UvPath {
}
function Install-Uv {
- Info "uv was not found. Installing uv..."
+ Info $Text.InstallingUv
- $tempScript = [System.IO.Path]::GetTempFileName() + ".ps1"
+ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
+ $null = New-Item -ItemType Directory -Path $tempDir
try {
- Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" -OutFile $tempScript -UseBasicParsing
- & $tempScript
- Update-UvPath
+ # PROCESSOR_ARCHITEW6432 identifies the native OS when PowerShell is emulated.
+ $nativeArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
+ $arch = switch ($nativeArch) {
+ 'AMD64' { 'x86_64' }
+ 'x86' { 'i686' }
+ 'ARM64' { 'aarch64' }
+ default { throw ($Text.UnsupportedArch -f $_) }
+ }
+ $artifact = "uv-$arch-pc-windows-msvc.zip"
+ $officialBaseUrl = 'https://github.com/astral-sh/uv/releases/latest/download'
+ foreach ($baseUrl in @($uvMirrorUrls) + $officialBaseUrl) {
+ try {
+ if ($baseUrl -eq $officialBaseUrl) {
+ Info "$($Text.Official): $officialBaseUrl"
+ } else {
+ Info ($Text.TryingMirror -f $baseUrl)
+ }
+ $archive = Join-Path $tempDir $artifact
+ $checksumFile = Join-Path $tempDir "$artifact.sha256"
+ Invoke-WebRequest -Uri "$baseUrl/$artifact" -OutFile $archive -TimeoutSec 90 -UseBasicParsing
+
+ # USTC protects larger release assets with a lightweight
+ # JavaScript cookie check. Reproduce that single documented
+ # browser step when its verification page is returned.
+ if (-not (Test-ZipArchive $archive) -and
+ ([Uri]$baseUrl).Host -eq 'mirrors.ustc.edu.cn') {
+ $challengePage = [System.IO.File]::ReadAllText($archive)
+ $addressMatch = [regex]::Match(
+ $challengePage,
+ 'document\.cookie\s*=\s*"addr=([0-9A-Fa-f:.]+);'
+ )
+ if ($addressMatch.Success) {
+ # Windows PowerShell 5.1 silently drops a manually set
+ # Cookie header. A WebRequestSession works in both 5.1
+ # and modern PowerShell.
+ $webSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession
+ $cookie = New-Object System.Net.Cookie(
+ 'addr',
+ $addressMatch.Groups[1].Value,
+ '/',
+ 'mirrors.ustc.edu.cn'
+ )
+ $webSession.Cookies.Add($cookie)
+ Invoke-WebRequest -Uri "$baseUrl/$artifact" -OutFile $archive `
+ -WebSession $webSession `
+ -TimeoutSec 90 -UseBasicParsing
+ }
+ }
+
+ if (-not (Test-ZipArchive $archive)) {
+ throw $Text.InvalidChecksum
+ }
+ Invoke-WebRequest -Uri "$baseUrl/$artifact.sha256" -OutFile $checksumFile -TimeoutSec 30 -UseBasicParsing
+ $expectedHash = ((Get-Content $checksumFile -Raw).Trim() -split '\s+')[0]
+ if ($expectedHash -notmatch '^[a-fA-F0-9]{64}$' -or
+ (Get-FileHash $archive -Algorithm SHA256).Hash -ne $expectedHash) {
+ throw $Text.InvalidChecksum
+ }
+ $unpacked = Join-Path $tempDir 'unpacked'
+ Expand-Archive -Path $archive -DestinationPath $unpacked -Force
+ if (-not (Test-Path (Join-Path $unpacked 'uv.exe')) -or
+ -not (Test-Path (Join-Path $unpacked 'uvx.exe'))) {
+ throw $Text.MissingBinaries
+ }
+ } catch {
+ Warn ($Text.MirrorFailed -f $_.Exception.Message)
+ Remove-Item (Join-Path $tempDir 'unpacked') -Recurse -Force -ErrorAction SilentlyContinue
+ continue
+ }
+
+ # Respect uv's installation directory settings for every source.
+ $installDir = if ($env:UV_INSTALL_DIR) { $env:UV_INSTALL_DIR }
+ elseif ($env:UV_UNMANAGED_INSTALL) { $env:UV_UNMANAGED_INSTALL }
+ elseif ($env:XDG_BIN_HOME) { $env:XDG_BIN_HOME }
+ elseif ($env:XDG_DATA_HOME) { Join-Path $env:XDG_DATA_HOME '..\bin' }
+ else { Join-Path $env:USERPROFILE '.local\bin' }
+ $null = New-Item -ItemType Directory -Path $installDir -Force
+ Copy-Item (Join-Path $unpacked '*.exe') -Destination $installDir -Force
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
+ if (-not $env:UV_UNMANAGED_INSTALL -and -not $env:INSTALLER_NO_MODIFY_PATH -and
+ ($userPath -split ';') -notcontains $installDir) {
+ [Environment]::SetEnvironmentVariable('Path', "$installDir;$userPath", 'User')
+ }
+ Update-UvPath
+ return
+ }
+ throw $Text.UvFailed
} catch {
- Err "Failed to install uv."
- Err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
+ Err $_.Exception.Message
+ Err $Text.UvFailed
exit 1
} finally {
- Remove-Item $tempScript -ErrorAction SilentlyContinue
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
+Info ($Text.Source -f $(switch ($DownloadSource) {
+ 'china' { $Text.China }
+ 'official' { $Text.Official }
+ 'custom' { $Text.Custom }
+}))
+foreach ($url in $uvMirrorUrls) { Info ($Text.UvMirror -f $url) }
+Info ($Text.Pypi -f $PypiIndexUrl)
+
+# Recover an existing installation before downloading uv again.
+Update-UvPath
if (-not (Test-Command "uv")) {
Install-Uv
}
@@ -75,12 +430,52 @@ if (-not (Test-Command "uv")) {
Update-UvPath
if (-not (Test-Command "uv")) {
- Err "uv was not found after installation."
- Err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
+ Err $Text.UvFailed
exit 1
}
Ok (& uv --version)
-Info "Installing AstrBot with Python 3.12..."
-uv tool install --python 3.12 astrbot
-Ok "AstrBot has been installed."
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+if ($PythonInstallMirror) {
+ Info ($Text.PythonMirror -f $PythonInstallMirror)
+} else {
+ Info $Text.PythonDefault
+}
+
+$previousPythonMirror = $env:UV_PYTHON_INSTALL_MIRROR
+try {
+ if ($PythonInstallMirror) { $env:UV_PYTHON_INSTALL_MIRROR = $PythonInstallMirror }
+ Info $Text.InstallingAstrBot
+ $toolInstallExitCode = Invoke-UvToolInstall -ArgumentList @(
+ 'tool', 'install', '--python', '3.12', 'astrbot', '--default-index', $PypiIndexUrl
+ )
+ if ($toolInstallExitCode -ne 0 -and $PythonInstallMirror) {
+ $pythonFallback = switch ($Language) {
+ 'zh-CN' { $bootstrapText.PythonFallbackChinese }
+ 'ja-JP' { $bootstrapText.PythonFallbackJapanese }
+ default { $bootstrapText.PythonFallbackEnglish }
+ }
+ Warn $pythonFallback
+ $env:UV_PYTHON_INSTALL_MIRROR = $null
+ $toolInstallExitCode = Invoke-UvToolInstall -ArgumentList @(
+ 'tool', 'install', '--python', '3.12', 'astrbot', '--default-index', $PypiIndexUrl
+ )
+ }
+ if ($toolInstallExitCode -ne 0) {
+ Err ($Text.AstrBotFailed -f $toolInstallExitCode)
+ exit $toolInstallExitCode
+ }
+ Ok $Text.Installed
+ try { & uv tool update-shell *> $null } catch { }
+ Write-Host "`n$Indent$($Text.ReloadShell)"
+ Write-Host "$($Indent) Bash: source ~/.bashrc"
+ Write-Host "$($Indent) Zsh: source ~/.zshrc"
+ Write-Host "$($Indent) Fish: source ~/.config/fish/config.fish"
+ Write-Host "$($Indent) PowerShell: . `$PROFILE"
+ Write-Host "`n$Indent$($Text.RunCommands)"
+ Write-Host "$($Indent)$($Indent)astrbot init" -ForegroundColor Cyan
+ Write-Host "$($Indent)$($Indent)astrbot run" -ForegroundColor Cyan
+} finally {
+ $env:UV_PYTHON_INSTALL_MIRROR = $previousPythonMirror
+}
diff --git a/docs/public/install.sh b/docs/public/install.sh
index 16caa08e839..700fcd7fe9e 100755
--- a/docs/public/install.sh
+++ b/docs/public/install.sh
@@ -1,68 +1,449 @@
#!/usr/bin/env bash
-# deploy-cli.sh - Install astrbot with uv on Linux / macOS / WSL.
+# Install AstrBot with uv on Linux / macOS / WSL. Compatible with Bash 3.2+.
set -euo pipefail
+language=''
+download_source=''
+uv_download_base_url=''
+pypi_index_url=''
+python_install_mirror=''
+non_interactive=0
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --language|--download-source|--uv-download-base-url|--pypi-index-url|--python-install-mirror)
+ if [ "$#" -lt 2 ] || [[ "$2" == --* ]] || [ -z "$2" ]; then
+ printf 'Missing value for %s\n' "$1" >&2
+ exit 2
+ fi
+ case "$1" in
+ --language) language="$2" ;;
+ --download-source) download_source="$2" ;;
+ --uv-download-base-url) uv_download_base_url="$2" ;;
+ --pypi-index-url) pypi_index_url="$2" ;;
+ --python-install-mirror) python_install_mirror="$2" ;;
+ esac
+ shift 2
+ ;;
+ --non-interactive) non_interactive=1; shift ;;
+ --help|-h)
+ cat <<'HELP'
+Usage: bash install.sh [options]
+ --language zh-CN|en-US|ja-JP
+ --download-source china|official|custom
+ --uv-download-base-url HTTPS-URL Directory containing uv archives and .sha256 files
+ --pypi-index-url HTTPS-URL PyPI simple index for custom sources
+ --python-install-mirror HTTPS-URL Optional Python interpreter download mirror
+ --non-interactive Skip all menus (defaults: English; source follows language)
+HELP
+ exit 0
+ ;;
+ *) printf 'Unknown option: %s\n' "$1" >&2; exit 2 ;;
+ esac
+done
+
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
- RED='\033[0;31m'
- GREEN='\033[0;32m'
- YELLOW='\033[1;33m'
- CYAN='\033[0;36m'
- NC='\033[0m'
+ RED=$'\033[0;31m'
+ GREEN=$'\033[0;32m'
+ YELLOW=$'\033[1;33m'
+ CYAN=$'\033[0;36m'
+ NC=$'\033[0m'
+ GRAY=$'\033[0;90m'
else
- RED=''
- GREEN=''
- YELLOW=''
- CYAN=''
- NC=''
+ RED=''; GREEN=''; YELLOW=''; CYAN=''; NC=''; GRAY=''
+fi
+
+indent=' '
+
+info() { printf '%s%s[INFO] %s%s\n' "$indent" "$CYAN" "$*" "$NC"; }
+warn() { printf '%s%s[WARN] %s%s\n' "$indent" "$YELLOW" "$*" "$NC"; }
+ok() { printf '%s%s[OK] %s%s\n' "$indent" "$GREEN" "$*" "$NC"; }
+err() { printf '%s%s[ERROR] %s%s\n' "$indent" "$RED" "$*" "$NC" >&2; }
+has() { command -v "$1" >/dev/null 2>&1; }
+
+# Read menus from the terminal, never from the script stream in curl | bash.
+can_prompt=0
+if [ "$non_interactive" -eq 0 ]; then
+ if [ -t 0 ]; then
+ exec 3<&0
+ can_prompt=1
+ elif { exec 3/dev/null; then
+ can_prompt=1
+ fi
fi
-info() { echo -e "${CYAN}[INFO]${NC} $*"; }
-warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
-ok() { echo -e "${GREEN}[OK]${NC} $*"; }
-err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
+if [ -z "$language" ]; then
+ # Match the PowerShell wizard regardless of the system locale.
+ language='en-US'
+ default_choice=1
+ if [ "$can_prompt" -eq 1 ]; then
+ printf '\n%s%s🌌 AstrBot Installer%s\n%s%s-----------------------%s\n' "$indent" "$CYAN" "$NC" "$indent" "$GRAY" "$NC" >&2
+ printf '\n%sSelect language / 请选择语言 / 言語を選択してください\n%s 1. English\n%s 2. 简体中文\n%s 3. 日本語\n' "$indent" "$indent" "$indent" "$indent" >&2
+ while :; do
+ printf '%s1-3 [%s]: ' "$indent" "$default_choice" >&2
+ IFS= read -r choice <&3 || exit 1
+ case "${choice:-$default_choice}" in
+ 1) language='en-US'; break ;;
+ 2) language='zh-CN'; break ;;
+ 3) language='ja-JP'; break ;;
+ esac
+ done
+ fi
+fi
+case "$language" in
+ zh-CN|en-US|ja-JP) ;;
+ *) err "Unsupported language: $language"; exit 2 ;;
+esac
-has() {
- command -v "$1" >/dev/null 2>&1
+message() {
+ local key="$1" format
+ shift
+ case "$language:$key" in
+ zh-CN:ChooseSource) format='请选择下载源' ;;
+ zh-CN:China) format='中国大陆加速下载镜像源' ;;
+ zh-CN:Official) format='官方源' ;;
+ zh-CN:Custom) format='自定义' ;;
+ zh-CN:Choice) format='输入编号,按回车使用默认选项' ;;
+ zh-CN:InvalidChoice) format='请输入 1、2 或 3。' ;;
+ zh-CN:UvUrl) format='uv tar.gz 下载目录 URL(不含文件名)' ;;
+ zh-CN:PypiUrl) format='PyPI simple 索引 URL' ;;
+ zh-CN:CustomRequired) format='自定义源需要同时提供 --uv-download-base-url 和 --pypi-index-url。' ;;
+ zh-CN:InvalidUrl) format='下载源必须是有效的 HTTPS URL:%s' ;;
+ zh-CN:Source) format='下载源:%s' ;;
+ zh-CN:UvMirror) format='uv 下载目录:%s' ;;
+ zh-CN:Pypi) format='PyPI 索引:%s' ;;
+ zh-CN:PythonMirror) format='Python 下载镜像:%s' ;;
+ zh-CN:PythonDefault) format='Python 下载沿用 uv 的配置;如需加速,可通过 --python-install-mirror 指定镜像(PyPI 镜像不包含 Python 解释器)。' ;;
+ zh-CN:InstallingUv) format='未找到 uv,正在安装…' ;;
+ zh-CN:TryingMirror) format='正在尝试 uv 镜像:%s' ;;
+ zh-CN:MirrorFailed) format='uv 镜像下载失败:%s' ;;
+ zh-CN:UnsupportedPlatform) format='不支持的平台或架构:%s' ;;
+ zh-CN:InvalidChecksum) format='uv 压缩包 SHA256 校验失败。' ;;
+ zh-CN:MissingBinaries) format='uv 压缩包中缺少 uv 或 uvx。' ;;
+ zh-CN:MissingTools) format='需要 curl 或 wget 下载文件;安装压缩包还需要 tar 和 sha256sum 或 shasum。' ;;
+ zh-CN:PathHint) format='uv 安装目录:%s。请确保新终端的 PATH 中包含此目录。' ;;
+ zh-CN:UvFailed) format='uv 安装失败。请参考 https://docs.astral.sh/uv/getting-started/installation/' ;;
+ zh-CN:InstallingAstrBot) format='正在使用 Python 3.12 安装 AstrBot…' ;;
+ zh-CN:AstrBotFailed) format='AstrBot 安装失败(退出码 %s)。请检查上方的下载或依赖错误。' ;;
+ zh-CN:PythonFallback) format='Python 镜像下载失败,正在切换到官方源重试…' ;;
+ zh-CN:ReloadShell) format='请根据你使用的终端重新加载环境:' ;;
+ zh-CN:RunCommands) format='重新加载后,请手动运行:' ;;
+ zh-CN:Installed) format='AstrBot 安装完成。' ;;
+ en-US:ChooseSource) format='Select a download source' ;;
+ en-US:China) format='Mainland China accelerated download mirrors' ;;
+ en-US:Official) format='Official sources' ;;
+ en-US:Custom) format='Custom' ;;
+ en-US:Choice) format='Enter a number, or press Enter to use the default' ;;
+ en-US:InvalidChoice) format='Please enter 1, 2, or 3.' ;;
+ en-US:UvUrl) format='uv tar.gz download directory URL (without the filename)' ;;
+ en-US:PypiUrl) format='PyPI simple index URL' ;;
+ en-US:CustomRequired) format='Custom sources require both --uv-download-base-url and --pypi-index-url.' ;;
+ en-US:InvalidUrl) format='Download sources must be valid HTTPS URLs: %s' ;;
+ en-US:Source) format='Download source: %s' ;;
+ en-US:UvMirror) format='uv download directory: %s' ;;
+ en-US:Pypi) format='PyPI index: %s' ;;
+ en-US:PythonMirror) format='Python download mirror: %s' ;;
+ en-US:PythonDefault) format='Python downloads use the existing uv configuration. Set --python-install-mirror to use a mirror (PyPI mirrors do not include Python interpreters).' ;;
+ en-US:InstallingUv) format='uv was not found. Installing uv...' ;;
+ en-US:TryingMirror) format='Trying uv mirror: %s' ;;
+ en-US:MirrorFailed) format='uv mirror download failed: %s' ;;
+ en-US:UnsupportedPlatform) format='Unsupported platform or architecture: %s' ;;
+ en-US:InvalidChecksum) format='uv archive SHA256 verification failed.' ;;
+ en-US:MissingBinaries) format='The uv archive is missing uv or uvx.' ;;
+ en-US:MissingTools) format='curl or wget is required to download files; archive installation also requires tar and sha256sum or shasum.' ;;
+ en-US:PathHint) format='uv installation directory: %s. Ensure it is on PATH in new terminals.' ;;
+ en-US:UvFailed) format='Failed to install uv. See https://docs.astral.sh/uv/getting-started/installation/' ;;
+ en-US:InstallingAstrBot) format='Installing AstrBot with Python 3.12...' ;;
+ en-US:AstrBotFailed) format='AstrBot installation failed (exit code %s). Check the download or dependency errors above.' ;;
+ en-US:PythonFallback) format='Python mirror failed; retrying with the official source...' ;;
+ en-US:ReloadShell) format='Reload the environment for your shell:' ;;
+ en-US:RunCommands) format='Then run these commands manually:' ;;
+ en-US:Installed) format='AstrBot has been installed.' ;;
+ ja-JP:ChooseSource) format='ダウンロード元を選択してください' ;;
+ ja-JP:China) format='中国本土向け高速ダウンロードミラー' ;;
+ ja-JP:Official) format='公式ソース' ;;
+ ja-JP:Custom) format='カスタム' ;;
+ ja-JP:Choice) format='番号を入力してください。Enter で既定値を使用します' ;;
+ ja-JP:InvalidChoice) format='1、2、3 のいずれかを入力してください。' ;;
+ ja-JP:UvUrl) format='uv tar.gz のダウンロードディレクトリ URL(ファイル名を除く)' ;;
+ ja-JP:PypiUrl) format='PyPI simple インデックス URL' ;;
+ ja-JP:CustomRequired) format='カスタムソースには --uv-download-base-url と --pypi-index-url の両方が必要です。' ;;
+ ja-JP:InvalidUrl) format='ダウンロード元には有効な HTTPS URL を指定してください:%s' ;;
+ ja-JP:Source) format='ダウンロード元:%s' ;;
+ ja-JP:UvMirror) format='uv ダウンロードディレクトリ:%s' ;;
+ ja-JP:Pypi) format='PyPI インデックス:%s' ;;
+ ja-JP:PythonMirror) format='Python ダウンロードミラー:%s' ;;
+ ja-JP:PythonDefault) format='Python のダウンロードには既存の uv 設定を使用します。ミラーを使うには --python-install-mirror を指定してください(PyPI ミラーに Python 本体は含まれません)。' ;;
+ ja-JP:InstallingUv) format='uv が見つかりません。インストールしています…' ;;
+ ja-JP:TryingMirror) format='uv ミラーを試しています:%s' ;;
+ ja-JP:MirrorFailed) format='uv ミラーからのダウンロードに失敗しました:%s' ;;
+ ja-JP:UnsupportedPlatform) format='未対応のプラットフォームまたはアーキテクチャ:%s' ;;
+ ja-JP:InvalidChecksum) format='uv アーカイブの SHA256 検証に失敗しました。' ;;
+ ja-JP:MissingBinaries) format='uv アーカイブに uv または uvx がありません。' ;;
+ ja-JP:MissingTools) format='ダウンロードには curl または wget が必要です。アーカイブのインストールには tar と sha256sum または shasum も必要です。' ;;
+ ja-JP:PathHint) format='uv のインストール先:%s。新しいターミナルでも PATH に含まれていることを確認してください。' ;;
+ ja-JP:UvFailed) format='uv のインストールに失敗しました。https://docs.astral.sh/uv/getting-started/installation/ を参照してください。' ;;
+ ja-JP:InstallingAstrBot) format='Python 3.12 で AstrBot をインストールしています…' ;;
+ ja-JP:AstrBotFailed) format='AstrBot のインストールに失敗しました(終了コード %s)。上記のダウンロードまたは依存関係のエラーを確認してください。' ;;
+ ja-JP:PythonFallback) format='Python ミラーに失敗したため、公式ソースで再試行しています…' ;;
+ ja-JP:ReloadShell) format='使用するシェルに合わせて環境を再読み込みしてください:' ;;
+ ja-JP:RunCommands) format='その後、以下のコマンドを手動で実行してください:' ;;
+ ja-JP:Installed) format='AstrBot のインストールが完了しました。' ;;
+ *) printf 'Unknown message: %s\n' "$key" >&2; return 1 ;;
+ esac
+ printf "$format\n" "$@"
}
+if [ -z "$download_source" ]; then
+ download_source='official'
+ if [ "$language" = 'zh-CN' ]; then download_source='china'; fi
+ if [ "$can_prompt" -eq 1 ]; then
+ default_choice=2
+ if [ "$language" = 'zh-CN' ]; then default_choice=1; fi
+ printf '\n%s%s\n%s 1. %s\n%s 2. %s\n%s 3. %s\n' \
+ "$indent" "$(message ChooseSource)" "$indent" "$(message China)" \
+ "$indent" "$(message Official)" "$indent" "$(message Custom)" >&2
+ while :; do
+ printf '%s%s [%s]: ' "$indent" "$(message Choice)" "$default_choice" >&2
+ IFS= read -r choice <&3 || exit 1
+ case "${choice:-$default_choice}" in
+ 1) download_source='china'; break ;;
+ 2) download_source='official'; break ;;
+ 3) download_source='custom'; break ;;
+ *) warn "$(message InvalidChoice)" ;;
+ esac
+ done
+ fi
+fi
+
+uv_mirror_urls=()
+case "$download_source" in
+ china)
+ source_label="$(message China)"
+ uv_mirror_urls=(
+ 'https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease'
+ )
+ pypi_index_url='https://mirrors.cernet.edu.cn/pypi/web/simple/'
+ ;;
+ official)
+ source_label="$(message Official)"
+ pypi_index_url='https://pypi.org/simple/'
+ ;;
+ custom)
+ source_label="$(message Custom)"
+ if [ "$can_prompt" -eq 1 ]; then
+ if [ -z "$uv_download_base_url" ]; then
+ printf '%s%s: ' "$indent" "$(message UvUrl)" >&2
+ IFS= read -r uv_download_base_url <&3 || exit 1
+ fi
+ if [ -z "$pypi_index_url" ]; then
+ printf '%s%s: ' "$indent" "$(message PypiUrl)" >&2
+ IFS= read -r pypi_index_url <&3 || exit 1
+ fi
+ fi
+ if [ -z "$uv_download_base_url" ] || [ -z "$pypi_index_url" ]; then
+ err "$(message CustomRequired)"; exit 2
+ fi
+ uv_mirror_urls=("${uv_download_base_url%/}")
+ ;;
+ *) err "Unsupported download source: $download_source"; exit 2 ;;
+esac
+
+if [ -z "$python_install_mirror" ]; then
+ python_install_mirror="${UV_PYTHON_INSTALL_MIRROR:-}"
+ if [ -z "$python_install_mirror" ] && [ "$download_source" = 'china' ]; then
+ python_install_mirror='https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/'
+ fi
+fi
+
+# Bash 3.2 treats empty arrays as unset with nounset; use the guarded expansion.
+for url in ${uv_mirror_urls[@]+"${uv_mirror_urls[@]}"} "$pypi_index_url" "$python_install_mirror"; do
+ [ -n "$url" ] || continue
+ if ! [[ "$url" =~ ^https://[^/?#[:space:]@]+(/[^?#[:space:]]*)?$ ]]; then
+ err "$(message InvalidUrl "$url")"; exit 2
+ fi
+done
+
+info "$(message Source "$source_label")"
+for url in ${uv_mirror_urls[@]+"${uv_mirror_urls[@]}"}; do info "$(message UvMirror "$url")"; done
+info "$(message Pypi "$pypi_index_url")"
+
refresh_uv_path() {
- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
+ local dir
+ for dir in "${UV_INSTALL_DIR:-}" "${UV_UNMANAGED_INSTALL:-}" "${XDG_BIN_HOME:-}" \
+ "${XDG_DATA_HOME:+$XDG_DATA_HOME/../bin}" "$HOME/.local/bin" "$HOME/.cargo/bin"; do
+ if [ -n "$dir" ] && [ -d "$dir" ]; then export PATH="$dir:$PATH"; fi
+ done
}
+download() {
+ if has curl; then
+ curl -fsSL --connect-timeout 15 --max-time 90 -o "$2" -- "$1"
+ elif has wget; then
+ wget -q -T 30 -O "$2" -- "$1"
+ else
+ err "$(message MissingTools)"
+ return 1
+ fi
+}
+
+install_temp_dir=''
+trap 'if [ -n "$install_temp_dir" ]; then rm -rf -- "$install_temp_dir"; fi' EXIT
+
install_uv() {
- info "uv was not found. Installing uv..."
+ info "$(message InstallingUv)"
+ install_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/astrbot-install.XXXXXX")"
+ local platform machine target='' base_url artifact archive expected_hash actual_hash install_dir
+ platform="$(uname -s)"
+ machine="$(uname -m)"
+ case "$platform:$machine" in
+ Darwin:arm64|Darwin:aarch64) target='aarch64-apple-darwin' ;;
+ Darwin:x86_64) target='x86_64-apple-darwin' ;;
+ Linux:x86_64|Linux:amd64) target='x86_64-unknown-linux-musl' ;;
+ Linux:aarch64|Linux:arm64) target='aarch64-unknown-linux-musl' ;;
+ Linux:i?86) target='i686-unknown-linux-musl' ;;
+ Linux:armv7l|Linux:armv8l) target='armv7-unknown-linux-musleabihf' ;;
+ Linux:armv6l) target='arm-unknown-linux-musleabihf' ;;
+ Linux:ppc64le) target='powerpc64le-unknown-linux-gnu' ;;
+ Linux:s390x) target='s390x-unknown-linux-gnu' ;;
+ Linux:riscv64) target='riscv64gc-unknown-linux-musl' ;;
+ esac
- if has curl; then
- curl -fsSL https://astral.sh/uv/install.sh | sh
- refresh_uv_path
- return
+ if [ -z "$target" ]; then
+ err "$(message UnsupportedPlatform "$platform/$machine")"
+ exit 1
+ fi
+ if ! has tar || { ! has sha256sum && ! has shasum; }; then
+ err "$(message MissingTools)"
+ exit 1
fi
- if has wget; then
- wget -qO- https://astral.sh/uv/install.sh | sh
+ artifact="uv-$target.tar.gz"
+ archive="$install_temp_dir/$artifact"
+ local official_base_url='https://github.com/astral-sh/uv/releases/latest/download'
+ for base_url in ${uv_mirror_urls[@]+"${uv_mirror_urls[@]}"} "$official_base_url"; do
+ if [ "$base_url" = "$official_base_url" ]; then
+ info "$(message Official): $official_base_url"
+ else
+ info "$(message TryingMirror "$base_url")"
+ fi
+ if ! download "$base_url/$artifact" "$archive"; then
+ warn "$(message MirrorFailed "$base_url")"
+ continue
+ fi
+
+ # USTC may return a cookie verification page instead of a release archive.
+ # Parse only the address cookie; never execute JavaScript from the response.
+ local signature address cookie
+ signature="$(od -An -tx1 -N2 "$archive" | tr -d ' \n')"
+ if [ "$signature" != '1f8b' ] && [[ "$base_url" == https://mirrors.ustc.edu.cn/* ]]; then
+ address="$(head -c 65536 "$archive" | sed -n 's/.*document\.cookie[[:space:]]*=[[:space:]]*"addr=\([0-9A-Fa-f:.]*\);.*/\1/p' | head -n 1)"
+ if [ -n "$address" ]; then
+ cookie="addr=$address"
+ if has curl; then
+ if ! curl -fsSL --connect-timeout 15 --max-time 90 -b "$cookie" -o "$archive" -- "$base_url/$artifact"; then
+ warn "$(message MirrorFailed "$base_url")"
+ continue
+ fi
+ else
+ if ! wget -q -T 30 --header="Cookie: $cookie" -O "$archive" -- "$base_url/$artifact"; then
+ warn "$(message MirrorFailed "$base_url")"
+ continue
+ fi
+ fi
+ fi
+ signature="$(od -An -tx1 -N2 "$archive" | tr -d ' \n')"
+ fi
+ if [ "$signature" != '1f8b' ]; then
+ warn "$(message InvalidChecksum)"
+ continue
+ fi
+ if ! download "$base_url/$artifact.sha256" "$install_temp_dir/checksum"; then
+ warn "$(message MirrorFailed "$base_url")"
+ continue
+ fi
+ expected_hash=''
+ read -r expected_hash _ < "$install_temp_dir/checksum" || true
+ if has sha256sum; then
+ actual_hash="$(sha256sum "$archive")"
+ else
+ actual_hash="$(shasum -a 256 "$archive")"
+ fi
+ actual_hash="${actual_hash%% *}"
+ if ! [[ "$expected_hash" =~ ^[a-fA-F0-9]{64}$ ]] || \
+ [ "$(printf '%s' "$expected_hash" | tr 'A-F' 'a-f')" != "$actual_hash" ]; then
+ warn "$(message InvalidChecksum)"
+ continue
+ fi
+ rm -rf -- "$install_temp_dir/unpacked"
+ mkdir -p "$install_temp_dir/unpacked"
+ if ! tar -xzf "$archive" -C "$install_temp_dir/unpacked" --strip-components=1; then
+ warn "$(message MirrorFailed "$base_url")"
+ continue
+ fi
+ if [ ! -f "$install_temp_dir/unpacked/uv" ] || [ ! -f "$install_temp_dir/unpacked/uvx" ]; then
+ warn "$(message MissingBinaries)"
+ continue
+ fi
+ install_dir="${UV_INSTALL_DIR:-${UV_UNMANAGED_INSTALL:-${XDG_BIN_HOME:-${XDG_DATA_HOME:+$XDG_DATA_HOME/../bin}}}}"
+ install_dir="${install_dir:-$HOME/.local/bin}"
+ mkdir -p "$install_dir"
+ cp "$install_temp_dir/unpacked/uv" "$install_temp_dir/unpacked/uvx" "$install_dir/"
+ chmod +x "$install_dir/uv" "$install_dir/uvx"
refresh_uv_path
+ info "$(message PathHint "$install_dir")"
return
- fi
-
- err "curl or wget is required to install uv."
+ done
+ err "$(message UvFailed)"
exit 1
}
-if has uv; then
- UV_BIN="uv"
+# Recover an existing installation before downloading uv again.
+refresh_uv_path
+if ! has uv; then install_uv; fi
+if ! has uv; then err "$(message UvFailed)"; exit 1; fi
+if uv_version="$(uv --version)"; then
+ ok "$uv_version"
else
- install_uv
- UV_BIN="uv"
+ status=$?
+ err "$(message UvFailed)"
+ exit "$status"
fi
-if ! has "$UV_BIN"; then
- err "uv was not found after installation."
- err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
- exit 1
-fi
-
-ok "$("$UV_BIN" --version)"
-info "Installing AstrBot with Python 3.12..."
-"$UV_BIN" tool install --python 3.12 astrbot
-ok "AstrBot has been installed."
+if [ -n "$python_install_mirror" ]; then
+ info "$(message PythonMirror "$python_install_mirror")"
+else
+ info "$(message PythonDefault)"
+fi
+info "$(message InstallingAstrBot)"
+# Use a valid working directory even if the caller's directory has been removed.
+# The subshell scopes Python mirror settings to this installation only.
+tool_install_exit_code=0
+(
+ cd "${TMPDIR:-/tmp}" || exit 1
+ UV_PYTHON_INSTALL_MIRROR="$python_install_mirror" \
+ uv tool install --python 3.12 astrbot --default-index "$pypi_index_url"
+) || tool_install_exit_code=$?
+if [ "$tool_install_exit_code" -ne 0 ] && [ -n "$python_install_mirror" ]; then
+ warn "$(message PythonFallback)"
+ tool_install_exit_code=0
+ (
+ cd "${TMPDIR:-/tmp}" || exit 1
+ unset UV_PYTHON_INSTALL_MIRROR
+ uv tool install --python 3.12 astrbot --default-index "$pypi_index_url"
+ ) || tool_install_exit_code=$?
+fi
+if [ "$tool_install_exit_code" -ne 0 ]; then
+ err "$(message AstrBotFailed "$tool_install_exit_code")"
+ exit "$tool_install_exit_code"
+fi
+ok "$(message Installed)"
+uv tool update-shell >/dev/null 2>&1 || true
+printf '\n%s%s\n' "$indent" "$(message ReloadShell)"
+printf '%s Bash: source ~/.bashrc\n' "$indent"
+printf '%s Zsh: source ~/.zshrc\n' "$indent"
+printf '%s Fish: source ~/.config/fish/config.fish\n' "$indent"
+printf '%s PowerShell: . $PROFILE\n' "$indent"
+printf '\n%s%s\n%s astrbot init\n%s astrbot run\n' \
+ "$indent" "$(message RunCommands)" "$indent" "$indent"
diff --git a/docs/zh/deploy/astrbot/launcher.md b/docs/zh/deploy/astrbot/launcher.md
index 588b3543d38..6251e403ca2 100644
--- a/docs/zh/deploy/astrbot/launcher.md
+++ b/docs/zh/deploy/astrbot/launcher.md
@@ -37,7 +37,7 @@ MacOS 用户下载安装好后,可能会遇到 "已损坏,无法打开" 的
### 运行安装器
-> 视频和此处不一致,请参考此处!!!如果部署不了,请参阅其他两个部署方式:Docker 部署和 手动部署。
+> 视频和此处不一致,请参考此处!如果部署不了,请参阅 Docker 部署或源码部署。
解压后,打开文件夹,
@@ -73,7 +73,7 @@ MacOS 用户下载安装好后,可能会遇到 "已损坏,无法打开" 的
> [!TIP]
-> 如果部署不了,请参阅其他两个部署方式:Docker 部署和 手动部署。
+> 如果部署不了,请参阅 Docker 部署或源码部署。
## 报错:Python is not installed
diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md
index 96a50f55a3b..8aa78f9940d 100644
--- a/docs/zh/deploy/astrbot/package.md
+++ b/docs/zh/deploy/astrbot/package.md
@@ -1,24 +1,65 @@
-# 包管理器部署(uv)
+# 脚本部署
-使用 `uv` 可以快速安装并启动 AstrBot。
+AstrBot 提供 Linux、macOS、WSL 和 Windows 安装脚本。脚本会安装 `uv`、AstrBot 及 Python 3.12。
-## 前置条件
+## Linux / macOS / WSL
-如果尚未安装 `uv`,请先按照官方文档安装:
+交互式安装:
-`uv` 支持 Linux、Windows、macOS。
+```bash
+curl -fsSL https://docs.astrbot.app/install.sh | bash
+```
+
+无人值守安装:
+
+```bash
+curl -fsSL https://docs.astrbot.app/install.sh -o install.sh
+bash install.sh --language zh-CN --download-source china --non-interactive
+```
+
+`--download-source` 可选 `china`、`official` 或 `custom`。省略语言参数时默认使用 English;未指定下载源时,中文使用中国大陆加速下载镜像源,英文和日文使用官方源。
+
+## Windows
+
+在 PowerShell 5.1 或 PowerShell 7+ 中运行:
+
+交互式安装:
-## 注意事项
+```powershell
+irm https://docs.astrbot.app/install.ps1 | iex
+```
+
+无人值守安装:
+
+```powershell
+Invoke-WebRequest https://docs.astrbot.app/install.ps1 -OutFile install.ps1 -UseBasicParsing
+.\install.ps1 -Language zh-CN -DownloadSource china -NonInteractive
+```
+
+`-DownloadSource` 可选 `china`、`official` 或 `custom`;省略语言参数时默认使用 English;未指定下载源时,中文使用中国大陆加速下载镜像源,英文和日文使用官方源。
-> [!WARNING]
-> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot --python 3.12`。
+选择中国大陆源时,脚本也会为 Python 3.12 配置 USTC 镜像,失败后使用官方 Python 源重试。已有的 Python 镜像环境变量或显式参数优先。
-AstrBot 需要 Python 3.12 或更高版本。使用 `--python 3.12` 可以确保 `uv` 使用 Python 3.12 创建 tool 环境;如果启用了 Python 自动下载,`uv` 会在缺少 Python 3.12 时自动下载。
+## 安装完成后
-## 安装并启动
+安装脚本会自动执行 `uv tool update-shell`。请根据你使用的终端重新加载环境:
```bash
-uv tool install astrbot --python 3.12
-astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
+# Bash
+source ~/.bashrc
+# Zsh
+source ~/.zshrc
+# Fish
+source ~/.config/fish/config.fish
+# PowerShell
+. $PROFILE
+```
+
+重新加载后,手动运行:
+
+```bash
+astrbot init
astrbot run
```
+
+> 通过脚本部署的 AstrBot 不支持在 WebUI 中升级。更新请执行 `uv tool upgrade astrbot --python 3.12`。
diff --git a/docs/zh/others/diagnostics.md b/docs/zh/others/diagnostics.md
index 92082db63c4..67afcfe5561 100644
--- a/docs/zh/others/diagnostics.md
+++ b/docs/zh/others/diagnostics.md
@@ -74,7 +74,7 @@ data/logs/event_loop_watchdog.log
提交问题时,请尽量提供以下信息:
- 问题发生的大致时间点和时区。
-- AstrBot 版本、部署方式(Docker、手动部署、桌面客户端等)、操作系统。
+- AstrBot 版本、部署方式(Docker、源码部署、桌面客户端等)、操作系统。
- 触发方式:启动、普通聊天、群聊、平台回调、定时任务、MCP 工具、插件功能等。
- 影响范围:所有会话、某个平台、某个群、某个用户,还是某个插件。
- `data/logs/astrbot.log` 中问题发生前后 1 到 3 分钟的日志。
diff --git a/tests/install_script.Tests.ps1 b/tests/install_script.Tests.ps1
new file mode 100644
index 00000000000..d6c61efb806
--- /dev/null
+++ b/tests/install_script.Tests.ps1
@@ -0,0 +1,181 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$scriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..\docs\public\install.ps1')).Path
+$scriptBytes = [System.IO.File]::ReadAllBytes($scriptPath)
+
+if ($scriptBytes.Length -ge 3 -and
+ $scriptBytes[0] -eq 0xEF -and
+ $scriptBytes[1] -eq 0xBB -and
+ $scriptBytes[2] -eq 0xBF) {
+ throw 'install.ps1 must be UTF-8 without BOM because Invoke-WebRequest preserves the BOM before Invoke-Expression.'
+}
+
+# Windows PowerShell 5.1 reads BOM-less files using the system ANSI code page.
+if (@($scriptBytes | Where-Object { $_ -gt 0x7F }).Count -ne 0) {
+ throw 'install.ps1 must use ASCII source with JSON Unicode escapes for localized messages.'
+}
+
+$tokens = $null
+$parseErrors = $null
+$ast = [System.Management.Automation.Language.Parser]::ParseFile(
+ $scriptPath,
+ [ref]$tokens,
+ [ref]$parseErrors
+)
+if ($parseErrors.Count -ne 0) {
+ throw "install.ps1 has parser errors: $($parseErrors.Message -join '; ')"
+}
+if (-not $ast.ParamBlock) {
+ throw 'install.ps1 must retain its top-level param block.'
+}
+if ($ast.Extent.Text -notmatch '\$bootstrapText = ConvertFrom-Json @''' -or
+ $ast.Extent.Text -notmatch '\$languages = @\(''en-US'', ''zh-CN'', ''ja-JP''\)' -or
+ $ast.Extent.Text -notmatch '\$defaultLanguage = ''en-US''' -or
+ $ast.Extent.Text -notmatch '\$Indent = '' ''' -or
+ $ast.Extent.Text -notmatch 'Write-Host "\$Indent\$Prefix \$Message"' -or
+ $ast.Extent.Text -notmatch 'Write-Host "\$\(\$Indent\)\$\(\$Indent\)astrbot init"' -or
+ $ast.Extent.Text -notmatch 'Write-Host "\$\(\$Indent\)\$\(\$Indent\)astrbot run"' -or
+ $ast.Extent.Text -notmatch 'if \(\$Language -eq ''zh-CN''\) \{ ''1'' \} else \{ ''2'' \}') {
+ throw 'The bootstrap menu must remain ASCII-only, default to English, and select the source default by language.'
+}
+if ($ast.Extent.Text -match 'mirror\.nyist\.edu\.cn' -or
+ $ast.Extent.Text -match 'https://astral\.sh/uv/install\.ps1' -or
+ $ast.Extent.Text -notmatch 'https://github\.com/astral-sh/uv/releases/latest/download' -or
+ $ast.Extent.Text -notmatch 'Microsoft\.PowerShell\.Commands\.WebRequestSession' -or
+ $ast.Extent.Text -notmatch '-WorkingDirectory \$workingDirectory -NoNewWindow -Wait -PassThru' -or
+ $ast.Extent.Text -match 'chcp\.com' -or
+ $ast.Extent.Text -match '\[Console\]::(?:Input|Output)Encoding') {
+ throw 'uv must use the verified ZIP flow, support the USTC cookie challenge, and keep installation in the current window.'
+}
+
+$zipFunctionAst = $ast.Find({
+ param($node)
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $node.Name -eq 'Test-ZipArchive'
+}, $true)
+if (-not $zipFunctionAst) { throw 'Test-ZipArchive is missing.' }
+Invoke-Expression $zipFunctionAst.Extent.Text
+
+$archiveTestDir = Join-Path ([System.IO.Path]::GetTempPath()) ("astrbot-install-test-{0}" -f [guid]::NewGuid())
+$null = New-Item -ItemType Directory -Path $archiveTestDir
+try {
+ $validArchive = Join-Path $archiveTestDir 'valid.zip'
+ $htmlResponse = Join-Path $archiveTestDir 'verification.html'
+ $shortFile = Join-Path $archiveTestDir 'short.bin'
+ [System.IO.File]::WriteAllBytes($validArchive, [byte[]](0x50, 0x4B, 0x03, 0x04))
+ [System.IO.File]::WriteAllText($htmlResponse, '
Verifying')
+ [System.IO.File]::WriteAllBytes($shortFile, [byte[]](0x50, 0x4B, 0x03))
+ if (-not (Test-ZipArchive $validArchive) -or
+ (Test-ZipArchive $htmlResponse) -or
+ (Test-ZipArchive $shortFile) -or
+ (Test-ZipArchive (Join-Path $archiveTestDir 'missing.zip'))) {
+ throw 'ZIP signature validation did not accept only a valid archive signature.'
+ }
+} finally {
+ Remove-Item -LiteralPath $archiveTestDir -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Item Function:\Test-ZipArchive -ErrorAction SilentlyContinue
+}
+
+$global:UvArguments = @()
+$global:UvPythonMirrors = @()
+$global:FailNextToolInstall = $false
+function global:uv {
+ $global:UvArguments += ,@($args)
+ $global:LASTEXITCODE = 0
+ if ($args.Count -eq 1 -and $args[0] -eq '--version') {
+ 'uv test-version'
+ } elseif ($args.Count -gt 1 -and $args[0] -eq 'tool' -and $args[1] -eq 'install') {
+ $global:UvPythonMirrors += [string]$env:UV_PYTHON_INSTALL_MIRROR
+ if ($global:FailNextToolInstall) {
+ $global:FailNextToolInstall = $false
+ $global:LASTEXITCODE = 1
+ }
+ }
+}
+
+$previousPythonMirror = $env:UV_PYTHON_INSTALL_MIRROR
+$env:UV_PYTHON_INSTALL_MIRROR = $null
+try {
+ & $scriptPath -Language en-US -DownloadSource official -NonInteractive
+ if ($LASTEXITCODE -ne 0) {
+ throw "Direct script execution failed with exit code $LASTEXITCODE."
+ }
+
+ # Decode the raw bytes ourselves so this exercises the same parser path as
+ # `Invoke-WebRequest ... | Invoke-Expression`, including any leading BOM.
+ $downloadedScript = [System.Text.Encoding]::UTF8.GetString($scriptBytes)
+ $downloadedScript | Invoke-Expression
+ if ($LASTEXITCODE -ne 0) {
+ throw "Invoke-Expression execution failed with exit code $LASTEXITCODE."
+ }
+ $versionCalls = @($global:UvArguments | Where-Object { $_.Count -eq 1 -and $_[0] -eq '--version' })
+ $installCalls = @($global:UvArguments | Where-Object {
+ $_.Count -eq 7 -and
+ $_[0] -eq 'tool' -and
+ $_[1] -eq 'install' -and
+ $_[2] -eq '--python' -and
+ $_[3] -eq '3.12' -and
+ $_[4] -eq 'astrbot' -and
+ $_[5] -eq '--default-index' -and
+ $_[6] -eq 'https://pypi.org/simple/'
+ })
+ if ($versionCalls.Count -ne 2 -or $installCalls.Count -ne 2) {
+ throw "Expected both execution modes to reach uv; got $($versionCalls.Count) version calls and $($installCalls.Count) install calls."
+ }
+
+ $global:FailNextToolInstall = $true
+ & $scriptPath -Language zh-CN -DownloadSource china -NonInteractive
+ if ($LASTEXITCODE -ne 0) {
+ throw "China mirror fallback failed with exit code $LASTEXITCODE."
+ }
+ $fallbackMirrors = @($global:UvPythonMirrors | Select-Object -Last 2)
+ $ustcMirror = 'https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/'
+ if ($fallbackMirrors.Count -ne 2 -or
+ $fallbackMirrors[0] -ne $ustcMirror -or
+ $fallbackMirrors[1] -ne '') {
+ throw "Expected USTC then official Python sources; got: $($fallbackMirrors -join ', ')"
+ }
+} finally {
+ $env:UV_PYTHON_INSTALL_MIRROR = $previousPythonMirror
+ Remove-Item Function:\uv -ErrorAction SilentlyContinue
+}
+
+$uvInstallFunctionAst = $ast.Find({
+ param($node)
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $node.Name -eq 'Invoke-UvToolInstall'
+}, $true)
+if (-not $uvInstallFunctionAst) { throw 'Invoke-UvToolInstall is missing.' }
+Invoke-Expression $uvInstallFunctionAst.Extent.Text
+
+$applicationTestDir = Join-Path ([System.IO.Path]::GetTempPath()) ("astrbot-uv-process-test-{0}" -f [guid]::NewGuid())
+$null = New-Item -ItemType Directory -Path $applicationTestDir
+$previousPath = $env:PATH
+$previousLocation = (Get-Location).Path
+try {
+ Copy-Item -LiteralPath "$env:SystemRoot\System32\cmd.exe" -Destination (Join-Path $applicationTestDir 'uv.exe')
+ $env:PATH = "$applicationTestDir;$env:PATH"
+
+ # Reproduce a shell whose current directory was removed by an earlier
+ # cleanup. Start-Process fails with "The directory name is invalid" unless
+ # Invoke-UvToolInstall supplies a valid working directory explicitly.
+ $staleCurrentDirectory = Join-Path $applicationTestDir 'deleted-current-directory'
+ $null = New-Item -ItemType Directory -Path $staleCurrentDirectory
+ Set-Location -LiteralPath $staleCurrentDirectory
+ [System.IO.Directory]::Delete($staleCurrentDirectory)
+ $exitCode = Invoke-UvToolInstall -ArgumentList @('/d', '/c', 'exit', '0')
+ if ($exitCode -ne 0) {
+ throw "The no-new-window uv process path returned exit code $exitCode."
+ }
+} finally {
+ Set-Location -LiteralPath $previousLocation
+ $env:PATH = $previousPath
+ Remove-Item -LiteralPath $applicationTestDir -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Item Function:\Invoke-UvToolInstall -ErrorAction SilentlyContinue
+}
+
+Write-Host "install.ps1 passed direct-file and Invoke-Expression tests on $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)."
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
new file mode 100644
index 00000000000..34901a829aa
--- /dev/null
+++ b/tests/test_install_script.py
@@ -0,0 +1,404 @@
+"""Behavioral tests for the Bash installation wizard without network access."""
+
+import hashlib
+import io
+import os
+import select
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import time
+import unittest
+from pathlib import Path
+
+try:
+ import pty
+except ImportError:
+ pty = None
+
+SCRIPT = Path(__file__).resolve().parents[1] / "docs/public/install.sh"
+CHINA_INDEX = "https://mirrors.cernet.edu.cn/pypi/web/simple/"
+OFFICIAL_INDEX = "https://pypi.org/simple/"
+PYTHON_MIRROR = (
+ "https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/"
+)
+
+FAKE_UV = b"""#!/bin/bash
+if [ "$1" = --version ]; then printf 'uv fixture\\n'; exit 0; fi
+if [ "$1" = tool ] && [ "$2" = update-shell ]; then touch "$TEST_DIR/uv-update-shell"; exit 0; fi
+printf '%s\\n' "$@" > "$TEST_DIR/uv-args"
+printf '%s\\n' "${UV_PYTHON_INSTALL_MIRROR:-}" >> "$TEST_DIR/python-mirrors"
+if [ "${TEST_FAIL_ONCE:-0}" = 1 ] && [ ! -f "$TEST_DIR/failed-once" ]; then
+ touch "$TEST_DIR/failed-once"
+ exit 1
+fi
+exit "${TEST_INSTALL_EXIT:-0}"
+"""
+
+# BASH_ENV supplies only fixture commands; uv itself is a real fixture executable.
+MOCK_ENV = r"""
+command() {
+ if [ "$1" = -v ]; then
+ case "$2" in
+ uv) [ -f "$UV_UNMANAGED_INSTALL/uv" ]; return $? ;;
+ curl) if [ "${TEST_WGET:-0}" = 1 ]; then return 1; fi ;;
+ esac
+ fi
+ builtin command "$@"
+}
+uv() { "$UV_UNMANAGED_INSTALL/uv" "$@"; }
+uname() {
+ case "$1" in
+ -s) printf '%s\n' "${TEST_PLATFORM:-Darwin}" ;;
+ -m) printf '%s\n' "${TEST_ARCH:-arm64}" ;;
+ *) /usr/bin/uname "$@" ;;
+ esac
+}
+mock_download() {
+ local url="$1" out="$2" cookie="$3"
+ printf '%s\n' "$url" >> "$TEST_DIR/requests"
+ if [ "${TEST_FAIL_ALL:-0}" = 1 ]; then return 22; fi
+ if [[ "$url" == https://mirrors.ustc.edu.cn/* ]]; then
+ if [ "${TEST_FAIL_PRIMARY:-0}" = 1 ]; then return 22; fi
+ if [ "${TEST_COOKIE:-0}" = 1 ] && [[ "$url" == *.tar.gz ]]; then
+ if [ "$cookie" != 'addr=192.0.2.1' ]; then
+ printf '' > "$out"
+ return
+ fi
+ printf '%s\n' "$cookie" > "$TEST_DIR/cookie"
+ fi
+ fi
+ case "$url" in
+ *.sha256)
+ if [ "${TEST_BAD_HASH:-0}" = 1 ] && [[ "$url" == https://mirrors.ustc.edu.cn/* ]]; then
+ printf '%064d fixture.tar.gz\n' 0 > "$out"
+ else cp "$TEST_DIR/checksum" "$out"; fi
+ ;;
+ *.tar.gz) cp "$TEST_DIR/fixture.tar.gz" "$out" ;;
+ *) printf 'Unexpected URL %s\n' "$url" >&2; return 1 ;;
+ esac
+}
+curl() {
+ local out='' url='' cookie=''
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -o) out="$2"; shift 2 ;;
+ -b) cookie="$2"; shift 2 ;;
+ --connect-timeout|--max-time) shift 2 ;;
+ --) shift; url="$1"; shift ;;
+ -*) shift ;;
+ *) url="$1"; shift ;;
+ esac
+ done
+ mock_download "$url" "$out" "$cookie"
+}
+wget() {
+ local out='' url='' cookie=''
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -O) out="$2"; shift 2 ;;
+ --header=*) cookie="${1#--header=Cookie: }"; shift ;;
+ -T|-t) shift 2 ;;
+ --) shift; url="$1"; shift ;;
+ -*) shift ;;
+ *) url="$1"; shift ;;
+ esac
+ done
+ mock_download "$url" "$out" "$cookie"
+}
+"""
+
+
+@unittest.skipUnless(pty is not None, "Bash installer tests require a POSIX terminal")
+class ShellInstallerTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory(prefix="astrbot-shell-test-")
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ (self.root / "fake-uv").write_bytes(FAKE_UV)
+ (self.root / "fake-uv").chmod(0o755)
+ (self.root / "env.sh").write_text(MOCK_ENV)
+ with tarfile.open(self.root / "fixture.tar.gz", "w:gz") as archive:
+ for name in ("uv", "uvx"):
+ member = tarfile.TarInfo(f"uv-fixture/{name}")
+ member.mode = 0o755
+ member.size = len(FAKE_UV)
+ archive.addfile(member, io.BytesIO(FAKE_UV))
+ checksum = hashlib.sha256(
+ (self.root / "fixture.tar.gz").read_bytes()
+ ).hexdigest()
+ (self.root / "checksum").write_text(f"{checksum} fixture.tar.gz\n")
+ self.env = os.environ.copy()
+ for key in (
+ "UV_INSTALL_DIR",
+ "UV_PYTHON_INSTALL_MIRROR",
+ "XDG_BIN_HOME",
+ "XDG_DATA_HOME",
+ ):
+ self.env.pop(key, None)
+ self.env.update(
+ BASH_ENV=str(self.root / "env.sh"),
+ TEST_DIR=str(self.root),
+ UV_UNMANAGED_INSTALL=str(self.root / "bin"),
+ NO_COLOR="1",
+ TMPDIR=str(self.root),
+ )
+
+ def run_installer(self, args, *, existing=True, answers=None, piped=False):
+ if existing:
+ (self.root / "bin").mkdir(exist_ok=True)
+ shutil.copy2(self.root / "fake-uv", self.root / "bin/uv")
+ command = ["/bin/bash"] + (["-s", "--"] if piped else [str(SCRIPT)]) + args
+ if answers is None:
+ result = subprocess.run(
+ command,
+ input=SCRIPT.read_bytes() if piped else b"",
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ env=self.env,
+ timeout=15,
+ start_new_session=True,
+ )
+ status, output = result.returncode, result.stdout.decode()
+ else:
+ # fork() attaches the child to a controlling terminal so /dev/tty
+ # still supplies menu input when stdin contains the script itself.
+ script_data = SCRIPT.read_bytes() if piped else b""
+ if piped:
+ read_fd, write_fd = os.pipe()
+ pid, terminal = pty.fork()
+ if pid == 0:
+ if piped:
+ os.close(write_fd)
+ os.dup2(read_fd, 0)
+ os.close(read_fd)
+ os.execve(command[0], command, self.env)
+ if piped:
+ os.close(read_fd)
+ os.set_blocking(write_fd, False)
+ output_bytes = bytearray()
+ pending = answers.encode()
+ deadline = time.monotonic() + 15
+ try:
+ while time.monotonic() < deadline:
+ readable, writable, _ = select.select(
+ [terminal], [write_fd] if script_data else [], [], 0.1
+ )
+ if writable:
+ written = os.write(write_fd, script_data)
+ script_data = script_data[written:]
+ if not script_data:
+ os.close(write_fd)
+ if readable:
+ try:
+ chunk = os.read(terminal, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ output_bytes.extend(chunk)
+ if pending and b"1-3 [1]:" in output_bytes:
+ os.write(terminal, pending)
+ pending = b""
+ else:
+ os.kill(pid, 9)
+ self.fail("Interactive installer timed out")
+ finally:
+ os.close(terminal)
+ if script_data:
+ os.close(write_fd)
+ _, wait_status = os.waitpid(pid, 0)
+ status = os.WEXITSTATUS(wait_status)
+ output = output_bytes.decode().replace("\r\n", "\n")
+ self.assertEqual(list(self.root.glob("astrbot-install.*")), [])
+ return status, output
+
+ def assert_install(self, index, mirrors):
+ self.assertEqual(
+ (self.root / "uv-args").read_text().splitlines(),
+ [
+ "tool",
+ "install",
+ "--python",
+ "3.12",
+ "astrbot",
+ "--default-index",
+ index,
+ ],
+ )
+ self.assertEqual(
+ (self.root / "python-mirrors").read_text().splitlines(), mirrors
+ )
+
+ def test_defaults_ignore_system_locale(self):
+ self.env["LANG"] = "zh_CN.UTF-8"
+ status, output = self.run_installer(["--non-interactive"])
+ self.assertEqual(status, 0, output)
+ self.assert_install(OFFICIAL_INDEX, [""])
+ self.assertTrue((self.root / "uv-update-shell").exists())
+ self.assertIn(" [INFO] Download source: Official sources", output)
+ self.assertIn(
+ "\n Reload the environment for your shell:\n"
+ " Bash: source ~/.bashrc\n"
+ " Zsh: source ~/.zshrc\n"
+ " Fish: source ~/.config/fish/config.fish\n"
+ " PowerShell: . $PROFILE\n\n"
+ " Then run these commands manually:\n"
+ " astrbot init\n"
+ " astrbot run\n",
+ output,
+ )
+
+ def test_chinese_defaults_to_all_china_mirrors(self):
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"]
+ )
+ self.assertEqual(status, 0, output)
+ self.assert_install(CHINA_INDEX, [PYTHON_MIRROR])
+ self.assertIn(" [INFO] 下载源:中国大陆加速下载镜像源", output)
+
+ def test_explicit_official_source_overrides_chinese_default(self):
+ status, output = self.run_installer(
+ [
+ "--language",
+ "zh-CN",
+ "--download-source",
+ "official",
+ "--non-interactive",
+ ]
+ )
+ self.assertEqual(status, 0, output)
+ self.assert_install(OFFICIAL_INDEX, [""])
+
+ def test_explicit_python_mirror_overrides_environment(self):
+ self.env["UV_PYTHON_INSTALL_MIRROR"] = "https://env.example.com/python"
+ status, output = self.run_installer(
+ [
+ "--download-source",
+ "china",
+ "--python-install-mirror",
+ "https://custom.example.com/python",
+ "--non-interactive",
+ ]
+ )
+ self.assertEqual(status, 0, output)
+ self.assert_install(CHINA_INDEX, ["https://custom.example.com/python"])
+
+ def test_existing_python_mirror_overrides_china_default(self):
+ self.env["UV_PYTHON_INSTALL_MIRROR"] = "https://env.example.com/python"
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"]
+ )
+ self.assertEqual(status, 0, output)
+ self.assert_install(CHINA_INDEX, ["https://env.example.com/python"])
+
+ def test_python_mirror_fallback_preserves_pypi(self):
+ self.env["TEST_FAIL_ONCE"] = "1"
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"]
+ )
+ self.assertEqual(status, 0, output)
+ self.assert_install(CHINA_INDEX, [PYTHON_MIRROR, ""])
+ self.assertIn("正在切换到官方源重试", output)
+
+ def test_install_failure_propagates_after_retry(self):
+ self.env["TEST_INSTALL_EXIT"] = "9"
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"]
+ )
+ self.assertEqual(status, 9, output)
+ self.assertNotIn("AstrBot 安装完成", output)
+ self.assertNotIn(" astrbot init", output)
+ self.assert_install(CHINA_INDEX, [PYTHON_MIRROR, ""])
+
+ def test_english_menu_and_empty_defaults_when_piped(self):
+ status, output = self.run_installer([], answers="\n\n", piped=True)
+ self.assertEqual(status, 0, output)
+ self.assertIn("\n 🌌 AstrBot Installer\n -----------------------", output)
+ self.assertIn(" 1. English\n 2. 简体中文\n 3. 日本語", output)
+ self.assertIn(" 1-3 [1]:", output)
+ self.assertIn(" 1. Mainland China accelerated download mirrors", output)
+ self.assertIn(
+ " Enter a number, or press Enter to use the default [2]:", output
+ )
+ self.assert_install(OFFICIAL_INDEX, [""])
+
+ def test_chinese_menu_default(self):
+ status, output = self.run_installer([], answers="2\n\n", piped=True)
+ self.assertEqual(status, 0, output)
+ self.assertIn(" 输入编号,按回车使用默认选项 [1]:", output)
+ self.assert_install(CHINA_INDEX, [PYTHON_MIRROR])
+
+ def test_japanese_custom_menu(self):
+ status, output = self.run_installer(
+ [],
+ answers="3\n0\n3\nhttps://uv.example.com/releases\nhttps://pypi.example.com/simple/\n",
+ piped=True,
+ )
+ self.assertEqual(status, 0, output)
+ self.assertIn(
+ " 番号を入力してください。Enter で既定値を使用します [2]:", output
+ )
+ self.assertIn(" 使用するシェルに合わせて環境を再読み込みしてください:", output)
+ self.assert_install("https://pypi.example.com/simple/", [""])
+
+ def test_official_archive_is_verified_without_installer_script(self):
+ status, output = self.run_installer(["--non-interactive"], existing=False)
+ self.assertEqual(status, 0, output)
+ requests = (self.root / "requests").read_text().splitlines()
+ base = "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz"
+ self.assertEqual(requests, [base, base + ".sha256"])
+
+ def test_linux_archive_selection(self):
+ self.env.update(TEST_PLATFORM="Linux", TEST_ARCH="x86_64")
+ status, output = self.run_installer(["--non-interactive"], existing=False)
+ self.assertEqual(status, 0, output)
+ self.assertIn(
+ "uv-x86_64-unknown-linux-musl.tar.gz", (self.root / "requests").read_text()
+ )
+
+ def test_primary_outage_uses_official_archive(self):
+ self.env["TEST_FAIL_PRIMARY"] = "1"
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"], existing=False
+ )
+ self.assertEqual(status, 0, output)
+ self.assertEqual(len((self.root / "requests").read_text().splitlines()), 3)
+
+ def test_checksum_mismatch_uses_official_archive(self):
+ self.env["TEST_BAD_HASH"] = "1"
+ status, output = self.run_installer(
+ ["--language", "zh-CN", "--non-interactive"], existing=False
+ )
+ self.assertEqual(status, 0, output)
+ self.assertIn("SHA256", output)
+ self.assertEqual(len((self.root / "requests").read_text().splitlines()), 4)
+
+ def test_cookie_challenge_curl(self):
+ self.env["TEST_COOKIE"] = "1"
+ status, output = self.run_installer(
+ ["--download-source", "china", "--non-interactive"], existing=False
+ )
+ self.assertEqual(status, 0, output)
+ self.assertEqual((self.root / "cookie").read_text(), "addr=192.0.2.1\n")
+ self.assertEqual(len((self.root / "requests").read_text().splitlines()), 3)
+
+ def test_cookie_challenge_wget(self):
+ self.env.update(TEST_COOKIE="1", TEST_WGET="1")
+ status, output = self.run_installer(
+ ["--download-source", "china", "--non-interactive"], existing=False
+ )
+ self.assertEqual(status, 0, output)
+ self.assertEqual((self.root / "cookie").read_text(), "addr=192.0.2.1\n")
+
+ def test_all_sources_fail_without_false_success(self):
+ self.env["TEST_FAIL_ALL"] = "1"
+ status, output = self.run_installer(["--non-interactive"], existing=False)
+ self.assertEqual(status, 1, output)
+ self.assertNotIn("AstrBot has been installed", output)
+ self.assertFalse((self.root / "uv-args").exists())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/tests/unit/test_core_lifecycle.py b/tests/unit/test_core_lifecycle.py
index 058f869fd61..f187677e164 100644
--- a/tests/unit/test_core_lifecycle.py
+++ b/tests/unit/test_core_lifecycle.py
@@ -895,8 +895,10 @@ async def test_restart_terminates_managers_and_starts_thread(
lifecycle.astrbot_updater = MagicMock()
- with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
- await lifecycle.restart()
+ # Replace only this module's reference; patching threading.Thread also
+ # disables the executor worker used by asyncio.to_thread during shutdown.
+ with patch("astrbot.core.core_lifecycle.threading") as mock_threading:
+ await asyncio.wait_for(lifecycle.restart(), timeout=5)
# Verify managers were terminated
lifecycle.provider_manager.terminate.assert_awaited_once()
@@ -904,8 +906,8 @@ async def test_restart_terminates_managers_and_starts_thread(
lifecycle.kb_manager.terminate.assert_awaited_once()
# Verify thread was started
- mock_thread.assert_called_once()
- mock_thread.return_value.start.assert_called_once()
+ mock_threading.Thread.assert_called_once()
+ mock_threading.Thread.return_value.start.assert_called_once()
class TestAstrBotCoreLifecycleLoadPipelineScheduler:
diff --git a/tests/unit/test_trace_store.py b/tests/unit/test_trace_store.py
index 1e38fa37368..0af2579ecd0 100644
--- a/tests/unit/test_trace_store.py
+++ b/tests/unit/test_trace_store.py
@@ -416,7 +416,12 @@ def test_database_pages_filters_details_and_restart(persisted, capture):
restarted.close()
-@pytest.mark.parametrize("prompt", ["x" * 8192, "字" * 20000, "x" * 131072])
+# Keep node IDs below Windows' PYTEST_CURRENT_TEST environment variable limit.
+@pytest.mark.parametrize(
+ "prompt",
+ ["x" * 8192, "字" * 20000, "x" * 131072],
+ ids=["ascii-8k", "unicode-20k", "ascii-128k"],
+)
def test_long_prompt_is_preserved_in_spans_events_and_database(
persisted, capture, prompt
):
@@ -530,11 +535,18 @@ def held_write(connection, batch):
assert persisted._pending_bytes == 0
-def test_stale_updates_do_not_reopen_completed_trace(persisted, capture):
+@pytest.mark.parametrize("same_timestamp", [False, True])
+def test_stale_updates_do_not_reopen_completed_trace(
+ persisted, capture, same_timestamp
+):
trace = TraceSpan("message", message_outline="Literal % _")
trace.record("event_received")
trace.record("pipeline_end", status="success")
for frame in reversed(capture):
+ # Wall-clock timestamps can collide on Windows even for distinct updates.
+ if same_timestamp:
+ frame["time_unix_nano"] = capture[-1]["time_unix_nano"]
+ frame["trace"]["updated_time_unix_nano"] = frame["time_unix_nano"]
assert persisted.append(frame)
assert persisted.flush()
detail = persisted.get_trace(trace.trace_id)