Compare commits

...

12 Commits

Author SHA1 Message Date
Soulter 447b4542d1 chore: bump version to 4.19.1 2026-03-05 01:38:54 +08:00
Soulter ead10b5643 refactor: remove runtime_bootstrap module and related initialization 2026-03-05 01:38:27 +08:00
Soulter 6beca2144c revert: #5729
This reverts commit a9c16febf4.
2026-03-05 01:34:07 +08:00
Soulter 2d27bfb6d0 revert: #5744
This reverts commit 3d1c3946f6.
2026-03-05 01:29:36 +08:00
エイカク 3d1c3946f6 feat(ci): add nightly prerelease release flow and updater support (#5744)
* feat: add nightly prerelease release flow and updater support

* feat(ci): auto-generate nightly release notes from latest stable tag

* fix(ci): correct nightly release notes heredoc YAML indentation

* fix(ci): align nightly notes heredoc terminator

* fix(ci): remove heredoc body indentation in nightly notes script

* fix: align nightly release metadata and prerelease rules

* fix: harden nightly release flow and updater release resolution

* fix: improve nightly branch resolution and updater logging

* fix: simplify updater target resolution and nightly release assets

* fix: avoid inputs lookup on non-dispatch release events

* fix: split nightly release fetch and simplify updater flow

* refactor: simplify updater target resolvers and nightly error checks

* fix: type release fetch errors and streamline updater resolution

* refactor: simplify updater target branching and release artifacts

* refactor: simplify release fetching and harden nightly git diagnostics

* fix: validate release payload shape before parsing

* refactor: harden prerelease handling and nightly constants

* refactor: derive archive urls and enrich fetch errors

* refactor: simplify update target resolution flow

* refactor: linearize update target resolution

* refactor: validate update target inputs and sync nightly tag source

* refactor: simplify updater mode resolution and prerelease tests

* refactor: simplify update target resolution flow

* fix: avoid package import when resolving nightly tag

* refactor: simplify updater resolution and centralize release constants

* fix: harden nightly release notes generation in workflow

* refactor: streamline update target resolution and errors

* refactor: simplify updater target resolution and nightly handling

* refactor: simplify updater errors and package release scripts

* refactor: centralize release api constants and loader

* fix(ci): resolve dispatch fallback tag from stable releases
2026-03-05 01:23:49 +09:00
Soulter cd434c5fed chore: bump version to 4.19.0 2026-03-04 23:39:52 +08:00
camera-2018 9683abeb19 feat(telegram): supports sendMessageDraft API (#5726)
* feat(telegram): 使用 sendMessageDraft API 实现私聊流式输出

- 新增 _send_message_draft 方法封装 Telegram Bot API sendMessageDraft
- 私聊流式输出使用 sendMessageDraft 推送草稿动画,群聊保留 edit_message_text 回退
- 使用独立异步发送循环 (_draft_sender_loop) 按固定间隔推送最新缓冲区内容,
  完全解耦 token 到达速度与 API 网络延迟
- 流式结束后发送真实消息保留最终内容(draft 是临时的)
- 使用模块级递增 draft_id 替代随机生成,确保 Telegram 端动画连续性

* fix(telegram): convert draft text to Markdown before sending message draft

* chore(telegram): telegram 适配器重构

- 提取公共方法
- 有新 token 到达时触发流式
- 生成结束后清除draft内容
- 默认draft发送md格式

* style(telegram): ruff format

* style(telegram): ruff check

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-04 23:11:57 +08:00
エイカク ab96537308 [codex] fix mcp init timeout keyword mismatch (#5743)
* fix: use timeout_seconds for mcp init startup

* fix: support overridden mcp init timeout in startup

* fix: resolve mcp init timeout from env when unset

* fix: pass mcp init timeout through lifecycle chain
2026-03-04 21:20:07 +09:00
Soulter 78fa58714c fix: require node.js env when uv sync 2026-03-04 18:25:19 +08:00
エイカク 9afe5757be feat: optimize async io performance and benchmark coverage (#5737)
* docs: align deployment sections across multilingual readmes

* docs: normalize deployment punctuation and AUR guidance

* docs: fix french and russian deployment wording

* perf: optimize async io hot paths and extend benchmarks

* fix: address async io review feedback

* fix: address follow-up async io review comments

* fix: align base64 io error handling in message components

* fix: harden attachment export ids and tune io chunking

* fix: preserve best-effort attachment export and batch writes

* test: expand path conversion and helper coverage
2026-03-04 16:26:34 +09:00
エイカク bbc8c62d43 docs: align deployment sections across multilingual readmes (#5734)
* docs: align deployment sections across multilingual readmes

* docs: normalize deployment punctuation and AUR guidance

* docs: fix french and russian deployment wording
2026-03-04 14:41:36 +09:00
エイカク a9c16febf4 fix: 工程化收敛并移除 ASYNC230/ASYNC240 忽略 (#5729)
* test(skills): align sandbox cache tests with readonly behavior

* ci(release): enforce core quality gate before publish

* ci: enforce locked dependency installs in workflows

* security: remove curl-pipe-shell installs

* chore: align project python baseline to 3.12

* ci(dashboard): add explicit typecheck gate

* chore(pre-commit): align ruff hook version with project

* ci(codeql): add javascript-typescript analysis

* chore(ruff): defer py312 migration lint rules

* fix: resolve ruff violations without new ignores

* fix: resolve ASYNC230 and ASYNC240 without ignores

* fix(auth): replace utcnow with timezone-aware UTC now

* fix: avoid blocking file read in file_to_base64
2026-03-04 13:51:00 +09:00
28 changed files with 1339 additions and 268 deletions
+34 -1
View File
@@ -184,7 +184,8 @@ jobs:
publish-pypi:
name: Publish PyPI
runs-on: ubuntu-24.04
needs: publish-release
needs:
- publish-release
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -192,6 +193,36 @@ jobs:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Download dashboard artifact
uses: actions/download-artifact@v8
with:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: dashboard-artifact
- name: Unpack dashboard dist into package tree
shell: bash
run: |
mkdir -p astrbot/dashboard/dist
unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked
cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -203,6 +234,8 @@ jobs:
- name: Build package
shell: bash
# Dashboard assets are already in astrbot/dashboard/dist/;
# ASTRBOT_BUILD_DASHBOARD is intentionally unset so the hatch hook skips npm.
run: uv build
- name: Publish to PyPI
+17 -15
View File
@@ -73,7 +73,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
### One-Click Deployment
For users who want to quickly experience AstrBot, we recommend using the one-click deployment method with `uv` ⚡️:
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
```bash
uv tool install astrbot
@@ -85,45 +85,47 @@ astrbot
### Docker Deployment
For users who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Deploy on RainYun
For users who want to deploy AstrBot with one-click and don't want to manage the server, we recommend using RainYun's one-click cloud deployment service ☁️:
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Desktop Application (Tauri)
### Desktop Application Deployment
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Visit [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is designed for desktop usage and is not recommended for server scenarios.
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
### Launcher Deployment
### One-Click Launcher Deployment (AstrBot Launcher)
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
For users who want a quick deployment and multi-instance solution with environment isolation, we recommend using the AstrBot Launcher:
Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and install the package for your OS from the latest release.
A quick deployment and multi-instance solution with environment isolation.
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
### Deploy on Replit
Community-contributed deployment method.
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
AUR deployment targets Arch Linux users who prefer installing AstrBot through the system package workflow.
Run the command below to install `astrbot-git`, then start AstrBot in your local environment.
```bash
yay -S astrbot-git
```
**More deployment methods**: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) | [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html)
**More deployment methods**
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
## Supported Messaging Platforms
+18 -16
View File
@@ -73,7 +73,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
### Déploiement en un clic
Pour les utilisateurs qui souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
```bash
uv tool install astrbot
@@ -85,45 +85,47 @@ astrbot
### Déploiement Docker
Pour les utilisateurs qui veulent un déploiement plus stable et prêt pour la production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Déployer sur RainYun
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Application de bureau (Tauri)
### Déploiement de l'application de bureau
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Accédez à [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer l'application ; cette méthode est conçue pour un usage desktop et n'est pas recommandée pour les scénarios serveur.
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. Solution de déploiement bureau en un clic, particulièrement adaptée aux débutants. Non recommandée pour les serveurs.
### Déploiement avec le lanceur
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
Pour les utilisateurs qui veulent une solution de déploiement rapide et multi-instances avec isolation d'environnement, nous recommandons d'utiliser AstrBot Launcher :
Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) et installez le package correspondant à votre système depuis la dernière release.
Une solution de déploiement rapide et multi-instances avec isolation d'environnement.
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
### Déployer sur Replit
Méthode de déploiement contribuée par la communauté.
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
Le mode AUR s'adresse aux utilisateurs Arch Linux qui préfèrent installer AstrBot via le gestionnaire de paquets système.
Exécutez la commande ci-dessous pour installer `astrbot-git`, puis lancez AstrBot localement.
```bash
yay -S astrbot-git
```
**Autres méthodes de déploiement** : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html)
**Autres méthodes de déploiement**
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
## Plateformes de messagerie prises en charge
+17 -15
View File
@@ -73,7 +73,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
### ワンクリックデプロイ
AstrBot を素早く試したいユーザーは、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` ワンクリックデプロイをおすすめします ⚡️:
```bash
uv tool install astrbot
@@ -85,45 +85,47 @@ astrbot
### Docker デプロイ
より安定した本番向けのデプロイを求めるユーザーには、Docker / Docker Compose で AstrBot デプロイすることをおすすめします。
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose で AstrBot デプロイをおすすめします。
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
### 雨云でのデプロイ
サーバー管理をせずに AstrBot をワンクリックでデプロイしたいユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
AstrBot をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### デスクトップクライアント(Tauri
### デスクトップアプリのデプロイ
デスクトップで AstrBot を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
デスクトップで AstrBot を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
### ランチャーのデプロイ
### ランチャーによるワンクリックデプロイ(AstrBot Launcher
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
### Replit でのデプロイ
コミュニティ貢献によるデプロイ方法
Replit デプロイはコミュニティ提供の方式で、オンラインデモや軽量な試用に向いています
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
AUR 方式は Arch Linux ユーザー向けで、システムのパッケージ運用に合わせて AstrBot を導入したい場合に適しています。
次のコマンドで `astrbot-git` をインストールし、ローカル環境で AstrBot を起動してください。
```bash
yay -S astrbot-git
```
**その他のデプロイ方法**[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) | [手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)
**その他のデプロイ方法**
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)`uv` とソースベースのフルカスタム導入)を参照してください。
## サポートされているメッセージプラットフォーム
+18 -16
View File
@@ -73,7 +73,7 @@ AstrBot — это универсальная платформа Agent-чатб
### Развёртывание в один клик
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
```bash
uv tool install astrbot
@@ -85,45 +85,47 @@ astrbot
### Развёртывание Docker
Для пользователей, которым нужен более стабильный и готовый к production вариант, мы рекомендуем развёртывать AstrBot через Docker / Docker Compose.
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
См. официальную документацию [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Развёртывание на RainYun
Для пользователей, которые хотят развернуть AstrBot в один клик и не управлять сервером самостоятельно, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять сервером, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Десктопное приложение (Tauri)
### Развёртывание десктопного приложения
Для пользователей, которые хотят использовать AstrBot на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
Для пользователей, которые хотят использовать AstrBot на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
### Развёртывание через лаунчер
### Установка в один клик через лаунчер (AstrBot Launcher)
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
### Развёртывание на Replit
Метод развёртывания от сообщества.
Развёртывание через Replit поддерживается сообществом и подходит для онлайн-демо и лёгких тестовых запусков.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
AUR-вариант предназначен для пользователей Arch Linux, которым удобна установка через системный менеджер пакетов.
Выполните команду ниже для установки `astrbot-git`, затем запустите AstrBot локально.
```bash
yay -S astrbot-git
```
**Другие способы развёртывания**: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html)
**Другие способы развёртывания**
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
## Поддерживаемые платформы обмена сообщениями
+17 -15
View File
@@ -73,7 +73,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
### 一鍵部署
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️
```bash
uv tool install astrbot
@@ -85,9 +85,9 @@ astrbot
### Docker 部署
對於希望獲得更穩定更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
對於熟悉容器、希望獲得更穩定更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
請參官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
請參官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
### 在雨雲上部署
@@ -95,35 +95,37 @@ astrbot
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### 桌面客戶端Tauri
### 桌面客戶端部署
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App
對於希望在桌面端使用 AstrBot、以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
### 啟動器部署
### 啟動器一鍵部署(AstrBot Launcher
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
一個快速部署和多開方案,實現環境隔離。
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
### 在 Replit 上部署
社群貢獻的部署方式
Replit 部署由社群維護,適合線上示範與輕量試用情境
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
AUR 方式面向 Arch Linux 使用者,適合希望透過系統套件管理器安裝 AstrBot 的場景。
在終端執行下方命令安裝 `astrbot-git` 套件,安裝完成後即可啟動使用。
```bash
yay -S astrbot-git
```
**更多部署方式**[寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手動部署](https://astrbot.app/deploy/astrbot/cli.html)
**更多部署方式**
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
## 支援的訊息平台
+17 -15
View File
@@ -73,7 +73,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
### 一键部署
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️
```bash
uv tool install astrbot
@@ -85,9 +85,9 @@ astrbot
### Docker 部署
对于希望获得更稳定更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
对于熟悉容器、希望获得更稳定更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
请参官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)
请参官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
### 在 雨云 上部署
@@ -95,35 +95,37 @@ astrbot
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### 桌面客户端Tauri
### 桌面客户端部署
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App
对于希望在桌面端使用 AstrBot、以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
### 启动器部署
### 启动器一键部署(AstrBot Launcher
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
一个快速部署和多开方案,实现环境隔离。
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
### 在 Replit 上部署
社区贡献的部署方式
Replit 部署由社区维护,适合在线演示和轻量试用场景
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
```bash
yay -S astrbot-git
```
**更多部署方式**[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
**更多部署方式**
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
## 支持的消息平台
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.18.3"
__version__ = "4.19.1"
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.18.3"
VERSION = "4.19.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
+6 -2
View File
@@ -97,7 +97,11 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)
async def initialize(self) -> None:
async def initialize(
self,
*,
mcp_init_timeout: float | int | str | None = None,
) -> None:
"""初始化 AstrBot 核心生命周期管理类.
负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
@@ -201,7 +205,7 @@ class AstrBotCoreLifecycle:
await self.plugin_manager.reload()
# 根据配置实例化各个 Provider
await self.provider_manager.initialize()
await self.provider_manager.initialize(init_timeout=mcp_init_timeout)
await self.kb_manager.initialize()
+308 -104
View File
@@ -1,6 +1,7 @@
import asyncio
import os
import re
from collections.abc import Callable
from typing import Any, cast
import telegramify_markdown
@@ -21,6 +22,7 @@ from astrbot.api.message_components import (
Video,
)
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
class TelegramPlatformEvent(AstrMessageEvent):
@@ -34,6 +36,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
"word": re.compile(r"\s"),
}
# sendMessageDraft 的 draft_id 类级递增计数器
_TELEGRAM_DRAFT_ID_MAX = 2_147_483_647
_next_draft_id: int = 0
@classmethod
def _allocate_draft_id(cls) -> int:
"""分配一个递增的 draft_id,溢出时归 1。"""
cls._next_draft_id = (
1
if cls._next_draft_id >= cls._TELEGRAM_DRAFT_ID_MAX
else cls._next_draft_id + 1
)
return cls._next_draft_id
# 消息类型到 chat action 的映射,用于优先级判断
ACTION_BY_TYPE: dict[type, str] = {
Record: ChatAction.UPLOAD_VOICE,
@@ -339,6 +355,118 @@ class TelegramPlatformEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"[Telegram] 添加反应失败: {e}")
async def _send_message_draft(
self,
chat_id: str,
draft_id: int,
text: str,
message_thread_id: str | None = None,
parse_mode: str | None = None,
) -> None:
"""通过 Bot.send_message_draft 发送草稿消息(流式推送部分消息)。
该 API 仅支持私聊。
Args:
chat_id: 目标私聊的 chat_id
draft_id: 草稿唯一标识,非零整数;相同 draft_id 的变更会以动画展示
text: 消息文本,1-4096 字符
message_thread_id: 可选,目标消息线程 ID
parse_mode: 可选,消息文本的解析模式
"""
kwargs: dict[str, Any] = {}
if message_thread_id:
kwargs["message_thread_id"] = int(message_thread_id)
if parse_mode:
kwargs["parse_mode"] = parse_mode
try:
logger.debug(
f"[Telegram] sendMessageDraft: chat_id={chat_id}, draft_id={draft_id}, text_len={len(text)}"
)
await self.client.send_message_draft(
chat_id=int(chat_id),
draft_id=draft_id,
text=text,
**kwargs,
)
except Exception as e:
logger.warning(f"[Telegram] sendMessageDraft 失败: {e!s}")
async def _process_chain_items(
self,
chain: MessageChain,
payload: dict[str, Any],
user_name: str,
message_thread_id: str | None,
on_text: Callable[[str], None],
) -> None:
"""处理 MessageChain 中的各类组件,文本通过 on_text 回调追加,媒体直接发送。"""
for i in chain.chain:
if isinstance(i, Plain):
on_text(i.text)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
photo=image_path,
**cast(Any, payload),
)
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_DOCUMENT,
self.client.send_document,
user_name=user_name,
document=path,
filename=name,
**cast(Any, payload),
)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self._send_voice_with_fallback(
self.client,
path,
payload,
caption=i.text or None,
user_name=user_name,
message_thread_id=message_thread_id,
use_media_action=True,
)
elif isinstance(i, Video):
path = await i.convert_to_file_path()
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_VIDEO,
self.client.send_video,
user_name=user_name,
video=path,
**cast(Any, payload),
)
else:
logger.warning(f"不支持的消息类型: {type(i)}")
async def _send_final_segment(self, delta: str, payload: dict[str, Any]) -> None:
"""将累积文本作为 MarkdownV2 真实消息发送,失败时回退到纯文本。"""
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.send_message(
text=markdown_text,
parse_mode="MarkdownV2",
**cast(Any, payload),
)
except Exception as e:
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
await self.client.send_message(text=delta, **cast(Any, payload))
async def send_streaming(self, generator, use_fallback: bool = False):
message_thread_id = None
@@ -356,6 +484,138 @@ class TelegramPlatformEvent(AstrMessageEvent):
if message_thread_id:
payload["message_thread_id"] = message_thread_id
# sendMessageDraft 仅支持私聊(显式检查 FRIEND_MESSAGE
is_private = self.get_message_type() == MessageType.FRIEND_MESSAGE
if is_private:
logger.info("[Telegram] 流式输出: 使用 sendMessageDraft (私聊)")
await self._send_streaming_draft(
user_name, message_thread_id, payload, generator
)
else:
logger.info("[Telegram] 流式输出: 使用 edit_message_text fallback (群聊)")
await self._send_streaming_edit(
user_name, message_thread_id, payload, generator
)
# 内联父类 send_streaming 的副作用(避免传入已消费的 generator)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
)
self._has_send_oper = True
async def _send_streaming_draft(
self,
user_name: str,
message_thread_id: str | None,
payload: dict[str, Any],
generator,
) -> None:
"""使用 sendMessageDraft API 进行流式推送(私聊专用)。
流式过程中使用 sendMessageDraft 推送草稿动画,
流式结束后发送一条真实消息保留最终内容(draft 是临时的,会消失)。
使用信号驱动的发送循环:每次有新 token 到达时唤醒发送,
发送频率由网络 RTT 自然限制(最多一个请求 in-flight)。
"""
draft_id = self._allocate_draft_id()
delta = ""
last_sent_text = ""
done = False # 信号:生成器已结束
text_changed = asyncio.Event() # 有新 token 到达时触发
async def _draft_sender_loop() -> None:
"""信号驱动的草稿发送循环,有新内容就发,RTT 自然限流。"""
nonlocal last_sent_text
while not done:
await text_changed.wait()
text_changed.clear()
# 发送最新的缓冲区内容(MarkdownV2 渲染,与真实消息一致)
if delta and delta != last_sent_text:
draft_text = delta[: self.MAX_MESSAGE_LENGTH]
if draft_text != last_sent_text:
try:
md = telegramify_markdown.markdownify(
draft_text,
normalize_whitespace=False,
)
await self._send_message_draft(
user_name,
draft_id,
md,
message_thread_id,
parse_mode="MarkdownV2",
)
last_sent_text = draft_text
except Exception:
# markdownify 对未闭合语法可能失败,回退纯文本
try:
await self._send_message_draft(
user_name,
draft_id,
draft_text,
message_thread_id,
)
last_sent_text = draft_text
except Exception as e2:
logger.debug(
f"[Telegram] sendMessageDraft failed (ignored): {e2!s}"
)
sender_task = asyncio.create_task(_draft_sender_loop())
def _append_text(t: str) -> None:
nonlocal delta
delta += t
text_changed.set() # 唤醒发送循环
try:
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
if chain.type == "break":
# 分割符:发送真实消息保留内容,重置缓冲区
if delta:
# 用 emoji 清空 draft 显示,避免 draft 和真实消息同时可见
await self._send_message_draft(
user_name,
draft_id,
"\u23f3",
message_thread_id,
)
await self._send_final_segment(delta, payload)
delta = ""
last_sent_text = ""
draft_id = self._allocate_draft_id()
continue
await self._process_chain_items(
chain, payload, user_name, message_thread_id, _append_text
)
finally:
done = True
text_changed.set() # 唤醒循环使其退出
await sender_task
# 流式结束:用 emoji 清空 draft,然后发真实消息持久化
if delta:
await self._send_message_draft(
user_name,
draft_id,
"\u23f3",
message_thread_id,
)
await self._send_final_segment(delta, payload)
async def _send_streaming_edit(
self,
user_name: str,
message_thread_id: str | None,
payload: dict[str, Any],
generator,
) -> None:
"""使用 send_message + edit_message_text 进行流式推送(群聊 fallback)。"""
delta = ""
current_content = ""
message_id = None
@@ -368,121 +628,67 @@ class TelegramPlatformEvent(AstrMessageEvent):
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = asyncio.get_event_loop().time()
def _append_text(t: str) -> None:
nonlocal delta
delta += t
async for chain in generator:
if isinstance(chain, MessageChain):
if chain.type == "break":
# 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None # 重置消息 ID
delta = "" # 重置 delta
continue
if not isinstance(chain, MessageChain):
continue
# 处理消息链中的每个组件
for i in chain.chain:
if isinstance(i, Plain):
delta += i.text
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
photo=image_path,
**cast(Any, payload),
if chain.type == "break":
# 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
continue
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_DOCUMENT,
self.client.send_document,
user_name=user_name,
document=path,
filename=name,
**cast(Any, payload),
)
continue
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self._send_voice_with_fallback(
self.client,
path,
payload,
caption=i.text or delta or None,
user_name=user_name,
message_thread_id=message_thread_id,
use_media_action=True,
)
continue
elif isinstance(i, Video):
path = await i.convert_to_file_path()
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_VIDEO,
self.client.send_video,
user_name=user_name,
video=path,
**cast(Any, payload),
)
continue
else:
logger.warning(f"不支持的消息类型: {type(i)}")
continue
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None
delta = ""
continue
# Plain
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
current_time = asyncio.get_event_loop().time()
time_since_last_edit = current_time - last_edit_time
await self._process_chain_items(
chain, payload, user_name, message_thread_id, _append_text
)
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
if time_since_last_edit >= throttle_interval:
# 发送 typing 状态(带节流)
current_time = asyncio.get_event_loop().time()
if current_time - last_chat_action_time >= chat_action_interval:
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = current_time
# 编辑消息
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
current_content = delta
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
last_edit_time = (
asyncio.get_event_loop().time()
) # 更新上次编辑的时间
else:
# delta 长度一般不会大于 4096,因此这里直接发送
# 发送 typing 状态(带节流)
# 编辑或发送消息
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
current_time = asyncio.get_event_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
current_time = asyncio.get_event_loop().time()
if current_time - last_chat_action_time >= chat_action_interval:
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = current_time
try:
msg = await self.client.send_message(
text=delta, **cast(Any, payload)
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
current_content = delta
except Exception as e:
logger.warning(f"发送消息失败(streaming): {e!s}")
message_id = msg.message_id
last_edit_time = (
asyncio.get_event_loop().time()
) # 记录初始消息发送时间
logger.warning(f"编辑消息失败(streaming): {e!s}")
last_edit_time = asyncio.get_event_loop().time()
else:
current_time = asyncio.get_event_loop().time()
if current_time - last_chat_action_time >= chat_action_interval:
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = current_time
try:
msg = await self.client.send_message(
text=delta, **cast(Any, payload)
)
current_content = delta
except Exception as e:
logger.warning(f"发送消息失败(streaming): {e!s}")
message_id = msg.message_id
last_edit_time = asyncio.get_event_loop().time()
try:
if delta and current_content != delta:
@@ -506,5 +712,3 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
return await super().send_streaming(generator, use_fallback)
+12 -4
View File
@@ -346,7 +346,10 @@ class FunctionToolManager:
logger.debug(f" 主机: {scheme}://{host}{port}")
async def init_mcp_clients(
self, raise_on_all_failed: bool = False
self,
raise_on_all_failed: bool = False,
*,
init_timeout: float | int | str | None = None,
) -> MCPInitSummary:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
@@ -367,6 +370,7 @@ class FunctionToolManager:
```
Timeout behavior:
- 显式 `init_timeout` 参数优先(用于测试或调用方覆盖)。
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
"""
@@ -383,8 +387,12 @@ class FunctionToolManager:
with open(mcp_json_file, encoding="utf-8") as f:
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
init_timeout = self._init_timeout_default
timeout_display = f"{init_timeout:g}"
init_timeout_value = _resolve_timeout(
timeout=init_timeout,
env_name=MCP_INIT_TIMEOUT_ENV,
default=self._init_timeout_default,
)
timeout_display = f"{init_timeout_value:g}"
active_configs: list[tuple[str, dict, asyncio.Event]] = []
for name, cfg in mcp_server_json_obj.items():
@@ -403,7 +411,7 @@ class FunctionToolManager:
name=name,
cfg=cfg,
shutdown_event=shutdown_event,
timeout=init_timeout,
timeout_seconds=init_timeout_value,
),
name=f"mcp-init:{name}",
)
+7 -2
View File
@@ -269,7 +269,11 @@ class ProviderManager:
return provider
async def initialize(self) -> None:
async def initialize(
self,
*,
init_timeout: float | int | str | None = None,
) -> None:
# 逐个初始化提供商
for provider_config in self.providers_config:
try:
@@ -338,7 +342,8 @@ class ProviderManager:
"on",
}
mcp_init_summary = await self.llm_tools.init_mcp_clients(
raise_on_all_failed=strict_mcp_init
raise_on_all_failed=strict_mcp_init,
init_timeout=init_timeout,
)
if (
mcp_init_summary.total > 0
+70
View File
@@ -0,0 +1,70 @@
## What's Changed
### 新增
- 集成 KOOK 平台适配器 ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658))。
- 集成 DeerFlow Agent Runner 并优化流式处理 ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581))。
- 新增 Discord pre-react Emoji 支持 ([#5609](https://github.com/AstrBotDevs/AstrBot/pull/5609))。
- 新增 Telegram 支持 `sendMessageDraft` 流式实时输出 API ([#5726](https://github.com/AstrBotDevs/AstrBot/issues/5726))
- 支持在 Agent 运行时进行消息跟进能力,跟进的消息实时注入给 Agent ([#5484](https://github.com/AstrBotDevs/AstrBot/pull/5484))。
- 新增 shell, ipython tool 中包含操作系统信息,提高 windows 下 tool call 成功率 ([#5677](https://github.com/AstrBotDevs/AstrBot/pull/5677))。
- Sandbox 支持 Shipyard-neo - 支持 Skills 自迭代 ([#5028](https://github.com/AstrBotDevs/AstrBot/pull/5028))。
- 新增 ChatUI WebSocket 传输模式选择,OpenAPI Chat API 支持 WebSocket 连接 ([#5410](https://github.com/AstrBotDevs/AstrBot/pull/5410))。
- 支持 Persona 自定义报错回复消息与兜底逻辑 ([#5547](https://github.com/AstrBotDevs/AstrBot/pull/5547))。
- 将 WebUI 静态文件打包至 wheel,并将 astrbot CLI 日志替换为英文 ([#5665](https://github.com/AstrBotDevs/AstrBot/pull/5665))。
- 增强聊天界面与移动端响应式体验 ([#5635](https://github.com/AstrBotDevs/AstrBot/pull/5635))。
- 优化插件失败处理逻辑与扩展列表交互体验 ([#5535](https://github.com/AstrBotDevs/AstrBot/pull/5535))。
### 修复
- 修复 MCP 初始化超时参数关键字不匹配的问题 ([#5743](https://github.com/AstrBotDevs/AstrBot/pull/5743))。
- 修复 MCP 工具竞态条件导致"completion 无法解析"错误 ([#5534](https://github.com/AstrBotDevs/AstrBot/pull/5534))。
- 修复 LINE 适配器中非 HTTPS URL 直接透传的问题 ([#5697](https://github.com/AstrBotDevs/AstrBot/pull/5697))。
- 修复 WebUI 侧边栏自定义状态不稳定的问题 ([#5670](https://github.com/AstrBotDevs/AstrBot/pull/5670))。
- 修复 KOOK 适配器收到消息和心跳响应时输出多余调试日志的问题。
- 修复 `DEMO_MODE` 环境变量未正确解析为布尔值的问题 ([#5676](https://github.com/AstrBotDevs/AstrBot/pull/5676))。
- 修复子 Agent 无法正确接收本地图片(参考图)路径的问题 ([#5579](https://github.com/AstrBotDevs/AstrBot/pull/5579))。
- 修复 `/model` 命令切换至不同 Provider 模型时产生误导性行为的问题 ([#5578](https://github.com/AstrBotDevs/AstrBot/pull/5578))。
- 修复对话记录中 UTC 时区偏移未处理导致时间戳异常的问题 ([#5580](https://github.com/AstrBotDevs/AstrBot/pull/5580))。
- 修复备份导入时重复平台统计数据导致异常的问题 ([#5594](https://github.com/AstrBotDevs/AstrBot/pull/5594))。
- 修复 `max_agent_step` 配置未应用到子 Agent 的问题 ([#5608](https://github.com/AstrBotDevs/AstrBot/pull/5608))。
- 修复插件列表排序和搜索过滤逻辑 ([#5559](https://github.com/AstrBotDevs/AstrBot/pull/5559))。
- 修复 `uv sync` 时未要求 Node.js 环境的问题。
---
## What's Changed (EN)
### New Features
- Integrated KOOK platform adapter ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658)).
- Integrated DeerFlow Agent Runner with optimized streaming support ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581)).
- feat(telegram): supports sendMessageDraft API ([#5726](https://github.com/AstrBotDevs/AstrBot/issues/5726))
- Integrated Neo skill self-iteration capability with full lifecycle management (candidate, release, deletion) via Shipyard Neo sandbox ([#5028](https://github.com/AstrBotDevs/AstrBot/pull/5028)).
- Added Discord pre-ack emoji support ([#5609](https://github.com/AstrBotDevs/AstrBot/pull/5609)).
- Added WebSocket transport mode selection for the chat interface ([#5410](https://github.com/AstrBotDevs/AstrBot/pull/5410)).
- Added OS information to tool descriptions with unit test coverage ([#5677](https://github.com/AstrBotDevs/AstrBot/pull/5677)).
- Added follow-up message handling in `ToolLoopAgentRunner` ([#5484](https://github.com/AstrBotDevs/AstrBot/pull/5484)).
- Added support for persona custom error reply messages with fallback logic ([#5547](https://github.com/AstrBotDevs/AstrBot/pull/5547)).
- Bundled WebUI static files into the wheel package and replaced astrbot CLI logs with English ([#5665](https://github.com/AstrBotDevs/AstrBot/pull/5665)).
- Optimized async IO performance and added benchmark coverage ([#5737](https://github.com/AstrBotDevs/AstrBot/pull/5737)).
- Refactored API key creation and added unit tests for open API routes.
- Improved error messaging for AI execution failures in agent runners.
- Enhanced chat interface and mobile responsiveness ([#5635](https://github.com/AstrBotDevs/AstrBot/pull/5635)).
- Improved plugin failure handling and extension list UX ([#5535](https://github.com/AstrBotDevs/AstrBot/pull/5535)).
### Bug Fixes
- Fixed MCP initialization timeout keyword mismatch ([#5743](https://github.com/AstrBotDevs/AstrBot/pull/5743)).
- Fixed MCP tools race condition causing `completion 无法解析` error ([#5534](https://github.com/AstrBotDevs/AstrBot/pull/5534)).
- Fixed LINE adapter allowing non-HTTPS URLs to pass through directly ([#5697](https://github.com/AstrBotDevs/AstrBot/pull/5697)).
- Fixed unstable sidebar customization state in WebUI ([#5670](https://github.com/AstrBotDevs/AstrBot/pull/5670)).
- Fixed excessive debug logging in KOOK adapter for received messages and heartbeat responses.
- Fixed `DEMO_MODE` environment variable not being parsed correctly as a boolean ([#5676](https://github.com/AstrBotDevs/AstrBot/pull/5676)).
- Fixed sub-agent failing to correctly receive local image (reference image) paths ([#5579](https://github.com/AstrBotDevs/AstrBot/pull/5579)).
- Fixed misleading behavior of the `/model` command when switching to a model from a different provider ([#5578](https://github.com/AstrBotDevs/AstrBot/pull/5578)).
- Fixed unhandled UTC timezone offset causing incorrect timestamps in conversation records ([#5580](https://github.com/AstrBotDevs/AstrBot/pull/5580)).
- Fixed backup import failure due to duplicate platform stats entries ([#5594](https://github.com/AstrBotDevs/AstrBot/pull/5594)).
- Fixed `max_agent_step` config not being applied to sub-agents ([#5608](https://github.com/AstrBotDevs/AstrBot/pull/5608)).
- Fixed plugin list sorting and search filtering logic ([#5559](https://github.com/AstrBotDevs/AstrBot/pull/5559)).
- Fixed missing Node.js environment requirement during `uv sync`.
-4
View File
@@ -5,10 +5,6 @@ import os
import sys
from pathlib import Path
import runtime_bootstrap
runtime_bootstrap.initialize_runtime_bootstrap()
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
from astrbot.core.config.default import VERSION # noqa: E402
from astrbot.core.initial_loader import InitialLoader # noqa: E402
+3 -3
View File
@@ -1,9 +1,9 @@
[project]
name = "AstrBot"
version = "4.18.3"
version = "4.19.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.11"
keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"]
@@ -39,7 +39,7 @@ dependencies = [
"pydantic>=2.12.5",
"pydub>=0.25.1",
"pyjwt>=2.10.1",
"python-telegram-bot>=22.0",
"python-telegram-bot>=22.6",
"qq-botpy>=1.2.1",
"quart>=0.20.0",
"readability-lxml>=0.8.4.1",
+1 -1
View File
@@ -32,7 +32,7 @@ py-cord>=2.6.1
pydantic>=2.12.5
pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.0
python-telegram-bot>=22.6
qq-botpy>=1.2.1
quart>=0.20.0
readability-lxml>=0.8.4.1
-50
View File
@@ -1,50 +0,0 @@
import logging
import ssl
from typing import Any
import aiohttp.connector as aiohttp_connector
from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
logger = logging.getLogger(__name__)
def _try_patch_aiohttp_ssl_context(
ssl_context: ssl.SSLContext,
log_obj: Any | None = None,
) -> bool:
log = log_obj or logger
attr_name = "_SSL_CONTEXT_VERIFIED"
if not hasattr(aiohttp_connector, attr_name):
log.warning(
"aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.",
)
return False
current_value = getattr(aiohttp_connector, attr_name, None)
if current_value is not None and not isinstance(current_value, ssl.SSLContext):
log.warning(
"aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.",
)
return False
setattr(aiohttp_connector, attr_name, ssl_context)
log.info("Configured aiohttp verified SSL context with system+certifi trust chain.")
return True
def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool:
log = log_obj or logger
try:
log.info("Bootstrapping runtime CA bundle.")
ssl_context = build_ssl_context_with_certifi(log_obj=log)
return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log)
except Exception as exc:
log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc)
return False
def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool:
return configure_runtime_ca_bundle(log_obj=log_obj)
+13 -1
View File
@@ -1,13 +1,20 @@
"""
Custom Hatchling build hook.
During `hatch build` (or `pip wheel`), this hook:
Only runs when the environment variable ASTRBOT_BUILD_DASHBOARD=1 is set,
so that `uv sync` / editable installs are never affected.
Usage:
ASTRBOT_BUILD_DASHBOARD=1 uv build
When enabled, this hook:
1. Runs `npm run build` inside the `dashboard/` directory.
2. Copies the resulting `dashboard/dist/` tree into
`astrbot/dashboard/dist/` so the static assets are shipped
inside the Python wheel.
"""
import os
import shutil
import subprocess
import sys
@@ -20,6 +27,11 @@ class CustomBuildHook(BuildHookInterface):
PLUGIN_NAME = "custom"
def initialize(self, version: str, build_data: dict) -> None:
# Only run when explicitly requested (e.g. during CI / release builds).
# This prevents `uv sync` / editable installs from triggering npm.
if os.environ.get("ASTRBOT_BUILD_DASHBOARD", "").strip() != "1":
return
root = Path(self.root)
dashboard_src = root / "dashboard"
dist_src = dashboard_src / "dist"
+20
View File
@@ -7,6 +7,7 @@ import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
from unittest.mock import AsyncMock, MagicMock
from astrbot.core.message.components import BaseMessageComponent
@@ -24,6 +25,25 @@ class NoopAwaitable:
return None
def get_bound_tcp_port(site: Any) -> int:
"""Resolve the bound aiohttp TCP site port for tests.
We prefer the public ``site.name`` first. Some aiohttp test setups with
ephemeral ports may not expose a usable port there, so we fall back to
``site._server.sockets`` as a test-only compatibility path.
"""
parsed = urlparse(getattr(site, "name", ""))
if parsed.port is not None and parsed.port > 0:
return parsed.port
server = getattr(site, "_server", None)
sockets = getattr(server, "sockets", None) if server else None
if sockets:
return sockets[0].getsockname()[1]
raise RuntimeError("Unable to resolve bound TCP port from aiohttp site")
# ============================================================
# 平台配置工厂
# ============================================================
+1
View File
@@ -110,6 +110,7 @@ class MockTelegramBuilder:
bot.set_my_commands = AsyncMock()
bot.set_message_reaction = AsyncMock()
bot.edit_message_text = AsyncMock()
bot.send_message_draft = AsyncMock()
return bot
@staticmethod
+268
View File
@@ -0,0 +1,268 @@
"""Performance benchmark tests for core AstrBot execution paths.
Run with:
uv run pytest tests/performance/test_benchmarks.py -q -s
Optional output:
ASTRBOT_BENCHMARK_OUTPUT=/tmp/astrbot_benchmark.json
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import time
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Awaitable, Callable
from unittest.mock import MagicMock
import pytest
from aiohttp import web
from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.message.components import File, Image, Record
from astrbot.core.utils.io import download_file, file_to_base64
from tests.fixtures.helpers import get_bound_tcp_port
@dataclass(slots=True)
class BenchmarkResult:
name: str
iterations: int
warmup: int
min_ms: float
max_ms: float
mean_ms: float
p50_ms: float
p95_ms: float
ops_per_sec: float
def _percentile(values: list[float], q: float) -> float:
if not values:
return 0.0
sorted_values = sorted(values)
if len(sorted_values) == 1:
return sorted_values[0]
rank = (len(sorted_values) - 1) * q
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return sorted_values[lower]
weight = rank - lower
return sorted_values[lower] * (1 - weight) + sorted_values[upper] * weight
async def run_async_benchmark(
name: str,
func: Callable[[], Awaitable[None]],
*,
iterations: int,
warmup: int = 5,
) -> BenchmarkResult:
for _ in range(warmup):
await func()
samples_ms: list[float] = []
for _ in range(iterations):
start_ns = time.perf_counter_ns()
await func()
elapsed_ms = (time.perf_counter_ns() - start_ns) / 1_000_000
samples_ms.append(elapsed_ms)
mean_ms = sum(samples_ms) / len(samples_ms)
return BenchmarkResult(
name=name,
iterations=iterations,
warmup=warmup,
min_ms=min(samples_ms),
max_ms=max(samples_ms),
mean_ms=mean_ms,
p50_ms=_percentile(samples_ms, 0.50),
p95_ms=_percentile(samples_ms, 0.95),
ops_per_sec=1000 / mean_ms if mean_ms > 0 else 0.0,
)
def _print_report(results: list[BenchmarkResult]) -> None:
print("\nAstrBot Benchmark Report")
print("-" * 84)
print(
f"{'case':35} {'iters':>7} {'mean(ms)':>10} {'p50(ms)':>10} "
f"{'p95(ms)':>10} {'ops/s':>10}"
)
print("-" * 84)
for result in results:
print(
f"{result.name:35} {result.iterations:7d} "
f"{result.mean_ms:10.4f} {result.p50_ms:10.4f} "
f"{result.p95_ms:10.4f} {result.ops_per_sec:10.1f}"
)
def _scaled_iterations(value: int) -> int:
scale = int(os.environ.get("ASTRBOT_BENCHMARK_SCALE", "1"))
return max(1, value * scale)
@pytest.mark.asyncio
@pytest.mark.slow
async def test_core_performance_benchmarks(tmp_path: Path) -> None:
"""Measure representative performance paths across core modules."""
data = os.urandom(256 * 1024)
payload_path = tmp_path / "payload.bin"
payload_path.write_bytes(data)
image = Image.fromFileSystem(str(payload_path))
record = Record.fromFileSystem(str(payload_path))
file_component = File(name="payload.bin", file=str(payload_path))
exists_path = tmp_path / "exists_target.txt"
exists_path.write_text("ok", encoding="utf-8")
attachments_dir = tmp_path / "attachments"
attachments_dir.mkdir()
attachments: list[dict[str, str]] = []
attachments_with_missing: list[dict[str, str]] = []
for i in range(64):
file_path = attachments_dir / f"attachment_{i}.bin"
file_path.write_bytes(data[:2048])
attachments.append({"attachment_id": f"att_{i}", "path": str(file_path)})
if i % 4 == 0:
missing_path = attachments_dir / f"missing_{i}.bin"
attachments_with_missing.append(
{"attachment_id": f"att_missing_{i}", "path": str(missing_path)}
)
attachments_with_missing.append(
{"attachment_id": f"att_existing_{i}", "path": str(file_path)}
)
exporter = AstrBotExporter(main_db=MagicMock())
zip_path = tmp_path / "attachments_bench.zip"
micro_batch = 32
download_target = tmp_path / "download_target.bin"
download_payload = os.urandom(512 * 1024)
async def handle_download(_request):
return web.Response(body=download_payload)
app = web.Application()
app.router.add_get("/download.bin", handle_download)
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = get_bound_tcp_port(site)
download_url = f"http://127.0.0.1:{port}/download.bin"
async def bench_file_to_base64() -> None:
await file_to_base64(str(payload_path))
async def bench_image_convert_to_base64() -> None:
await image.convert_to_base64()
async def bench_record_convert_to_base64() -> None:
await record.convert_to_base64()
async def bench_image_convert_to_file_path() -> None:
for _ in range(micro_batch):
await image.convert_to_file_path()
async def bench_file_component_get_file() -> None:
await file_component.get_file()
async def bench_to_thread_exists() -> None:
await asyncio.to_thread(exists_path.exists)
async def bench_export_attachments_existing() -> None:
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
await exporter._export_attachments(zf, attachments)
zip_path.unlink(missing_ok=True)
async def bench_export_attachments_with_missing() -> None:
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
await exporter._export_attachments(zf, attachments_with_missing)
zip_path.unlink(missing_ok=True)
async def bench_download_file_local_http() -> None:
await download_file(download_url, str(download_target))
download_target.unlink(missing_ok=True)
try:
results = [
await run_async_benchmark(
"utils.io.file_to_base64(256KB)",
bench_file_to_base64,
iterations=_scaled_iterations(120),
),
await run_async_benchmark(
"components.Image.convert_to_base64",
bench_image_convert_to_base64,
iterations=_scaled_iterations(120),
),
await run_async_benchmark(
"components.Record.convert_to_base64",
bench_record_convert_to_base64,
iterations=_scaled_iterations(120),
),
await run_async_benchmark(
f"components.Image.convert_to_file_path(x{micro_batch})",
bench_image_convert_to_file_path,
iterations=_scaled_iterations(140),
),
await run_async_benchmark(
"components.File.get_file(local)",
bench_file_component_get_file,
iterations=_scaled_iterations(140),
),
await run_async_benchmark(
"asyncio.to_thread(Path.exists)",
bench_to_thread_exists,
iterations=_scaled_iterations(240),
),
await run_async_benchmark(
"backup.exporter._export_attachments(existing)",
bench_export_attachments_existing,
iterations=_scaled_iterations(20),
warmup=2,
),
await run_async_benchmark(
"backup.exporter._export_attachments(mixed)",
bench_export_attachments_with_missing,
iterations=_scaled_iterations(20),
warmup=2,
),
await run_async_benchmark(
"utils.io.download_file(local_http_512KB)",
bench_download_file_local_http,
iterations=_scaled_iterations(12),
warmup=2,
),
]
finally:
await runner.cleanup()
_print_report(results)
output_path = os.environ.get("ASTRBOT_BENCHMARK_OUTPUT")
if output_path:
Path(output_path).write_text(
json.dumps([asdict(result) for result in results], indent=2),
encoding="utf-8",
)
# Keep assertions broad: benchmarks are for measurement, not strict gating.
assert len(results) == 9
for result in results:
assert result.iterations > 0
assert result.mean_ms > 0
assert result.max_ms >= result.min_ms
assert result.p95_ms >= result.p50_ms
+98
View File
@@ -172,6 +172,15 @@ class TestAstrBotExporter:
assert "test.json" in exporter._checksums
assert exporter._checksums["test.json"].startswith("sha256:")
def test_read_text_if_exists(self, tmp_path):
"""测试 _read_text_if_exists 行为。"""
exporter = AstrBotExporter(main_db=MagicMock())
file_path = tmp_path / "config.json"
file_path.write_text('{"k":"v"}', encoding="utf-8")
assert exporter._read_text_if_exists(str(file_path)) == '{"k":"v"}'
assert exporter._read_text_if_exists(str(tmp_path / "missing.json")) is None
def test_generate_manifest(self, mock_main_db, mock_kb_manager):
"""测试生成清单"""
exporter = AstrBotExporter(
@@ -240,6 +249,95 @@ class TestAstrBotExporter:
assert "databases/main_db.json" in namelist
assert "config/cmd_config.json" in namelist
@pytest.mark.asyncio
async def test_export_attachments_exports_existing_and_skips_missing(
self, mock_main_db, tmp_path
):
"""测试附件导出:存在文件写入 ZIP,不存在文件跳过。"""
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
existing_file = tmp_path / "exists.txt"
existing_file.write_text("hello", encoding="utf-8")
missing_file = tmp_path / "missing.txt"
zip_path = tmp_path / "attachments.zip"
attachments = [
{"attachment_id": "att_ok", "path": str(existing_file)},
{"attachment_id": "att_missing", "path": str(missing_file)},
]
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
await exporter._export_attachments(zf, attachments)
with zipfile.ZipFile(zip_path, "r") as zf:
namelist = zf.namelist()
assert "files/attachments/att_ok.txt" in namelist
assert "files/attachments/att_missing.txt" not in namelist
@pytest.mark.asyncio
async def test_export_attachments_skips_empty_attachment_id(
self, mock_main_db, tmp_path
):
"""测试附件导出:attachment_id 为空时跳过,避免覆盖冲突。"""
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
file_a = tmp_path / "a.txt"
file_b = tmp_path / "b.txt"
file_a.write_text("a", encoding="utf-8")
file_b.write_text("b", encoding="utf-8")
zip_path = tmp_path / "attachments_empty_id.zip"
attachments = [
{"attachment_id": "", "path": str(file_a)},
{"path": str(file_b)},
{"attachment_id": "att_ok", "path": str(file_a)},
]
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
await exporter._export_attachments(zf, attachments)
with zipfile.ZipFile(zip_path, "r") as zf:
namelist = zf.namelist()
assert "files/attachments/att_ok.txt" in namelist
assert "files/attachments/.txt" not in namelist
@pytest.mark.asyncio
async def test_export_attachments_keeps_best_effort_on_unexpected_write_error(
self, mock_main_db, tmp_path
):
"""测试附件导出:单个非 OSError 写入异常不会中断后续附件导出。"""
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
file_a = tmp_path / "a.txt"
file_b = tmp_path / "b.txt"
file_a.write_text("a", encoding="utf-8")
file_b.write_text("b", encoding="utf-8")
zip_path = tmp_path / "attachments_best_effort.zip"
attachments = [
{"attachment_id": "att_boom", "path": str(file_a)},
{"attachment_id": "att_ok", "path": str(file_b)},
]
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
original_write = zf.write
def flaky_write(filename, arcname=None, *args, **kwargs):
if arcname == "files/attachments/att_boom.txt":
raise RuntimeError("boom")
return original_write(filename, arcname, *args, **kwargs)
with patch.object(zf, "write", side_effect=flaky_write):
await exporter._export_attachments(zf, attachments)
with zipfile.ZipFile(zip_path, "r") as zf:
namelist = zf.namelist()
assert "files/attachments/att_boom.txt" not in namelist
assert "files/attachments/att_ok.txt" in namelist
class TestAstrBotImporter:
"""AstrBotImporter 类测试"""
+2 -2
View File
@@ -373,7 +373,7 @@ class TestAstrBotCoreLifecycleInitialize:
new_callable=AsyncMock,
),
):
await lifecycle.initialize()
await lifecycle.initialize(mcp_init_timeout=3.5)
# Verify database initialized
mock_db.initialize.assert_awaited_once()
@@ -388,7 +388,7 @@ class TestAstrBotCoreLifecycleInitialize:
mock_persona_mgr.initialize.assert_awaited_once()
# Verify provider manager initialized
mock_provider_manager.initialize.assert_awaited_once()
mock_provider_manager.initialize.assert_awaited_once_with(init_timeout=3.5)
# Verify platform manager initialized
mock_platform_manager.initialize.assert_awaited_once()
+43
View File
@@ -0,0 +1,43 @@
import pytest
from tests.fixtures.helpers import get_bound_tcp_port
class _DummySiteNoAttrs:
pass
class _DummySocket:
def __init__(self, port: int) -> None:
self._port = port
def getsockname(self):
return ("127.0.0.1", self._port)
class _DummyServer:
def __init__(self, port: int) -> None:
self.sockets = [_DummySocket(port)]
class _DummySiteWithName:
def __init__(self, port: int) -> None:
self.name = f"http://localhost:{port}"
class _DummySiteWithServer:
def __init__(self, port: int) -> None:
self._server = _DummyServer(port)
def test_get_bound_tcp_port_raises_on_unresolvable_site():
with pytest.raises(RuntimeError, match="Unable to resolve bound TCP port"):
get_bound_tcp_port(_DummySiteNoAttrs())
def test_get_bound_tcp_port_uses_name_port_when_available():
assert get_bound_tcp_port(_DummySiteWithName(8081)) == 8081
def test_get_bound_tcp_port_falls_back_to_server_sockets():
assert get_bound_tcp_port(_DummySiteWithServer(9092)) == 9092
+98
View File
@@ -0,0 +1,98 @@
import json
import pytest
from astrbot.core.provider import func_tool_manager
from astrbot.core.provider.func_tool_manager import FunctionToolManager
@pytest.fixture
def mcp_init_harness(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
manager = FunctionToolManager()
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "mcp_server.json").write_text(
json.dumps({"mcpServers": {"demo": {"active": True}}}),
encoding="utf-8",
)
monkeypatch.setattr(
func_tool_manager,
"get_astrbot_data_path",
lambda: data_dir,
)
called = {}
async def fake_start_mcp_server(*, name, cfg, shutdown_event, timeout_seconds):
called[name] = {
"cfg": cfg,
"shutdown_event_type": type(shutdown_event).__name__,
"timeout_seconds": timeout_seconds,
}
monkeypatch.setattr(manager, "_start_mcp_server", fake_start_mcp_server)
return manager, called
def assert_demo_init_result(summary, called, *, timeout_seconds: float) -> None:
assert summary.total == 1
assert summary.success == 1
assert summary.failed == []
assert called["demo"]["cfg"] == {"active": True}
assert called["demo"]["shutdown_event_type"] == "Event"
assert called["demo"]["timeout_seconds"] == timeout_seconds
@pytest.mark.asyncio
async def test_init_mcp_clients_passes_timeout_seconds_keyword(mcp_init_harness):
manager, called = mcp_init_harness
summary = await manager.init_mcp_clients()
assert_demo_init_result(
summary,
called,
timeout_seconds=manager._init_timeout_default,
)
@pytest.mark.asyncio
async def test_init_mcp_clients_passes_overridden_init_timeout(
mcp_init_harness,
):
manager, called = mcp_init_harness
summary = await manager.init_mcp_clients(init_timeout=3.5)
assert_demo_init_result(summary, called, timeout_seconds=3.5)
@pytest.mark.asyncio
async def test_init_mcp_clients_reads_env_timeout_when_not_overridden(
mcp_init_harness,
monkeypatch: pytest.MonkeyPatch,
):
manager, called = mcp_init_harness
manager._init_timeout_default = 20.0 # ensure env override is observable
monkeypatch.setenv("ASTRBOT_MCP_INIT_TIMEOUT", "3.5")
summary = await manager.init_mcp_clients()
assert_demo_init_result(summary, called, timeout_seconds=3.5)
@pytest.mark.asyncio
async def test_init_mcp_clients_prefers_explicit_timeout_over_env(
mcp_init_harness,
monkeypatch: pytest.MonkeyPatch,
):
manager, called = mcp_init_harness
monkeypatch.setenv("ASTRBOT_MCP_INIT_TIMEOUT", "7.0")
summary = await manager.init_mcp_clients(init_timeout=3.5)
assert_demo_init_result(summary, called, timeout_seconds=3.5)
+71
View File
@@ -0,0 +1,71 @@
import pytest
from aiohttp import web
from astrbot.core.utils import io as io_module
from astrbot.core.utils.io import _stream_to_file, download_file
from tests.fixtures.helpers import get_bound_tcp_port
@pytest.mark.asyncio
async def test_download_file_downloads_content(tmp_path):
payload = b"astrbot-download-payload" * 256
async def handle(_request):
return web.Response(body=payload)
app = web.Application()
app.router.add_get("/file.bin", handle)
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
try:
port = get_bound_tcp_port(site)
url = f"http://127.0.0.1:{port}/file.bin"
out = tmp_path / "downloaded.bin"
await download_file(url, str(out))
assert out.read_bytes() == payload
finally:
await runner.cleanup()
class _DummyStream:
def __init__(self, chunks: list[bytes]) -> None:
self._chunks = chunks
async def read(self, _size: int) -> bytes:
if not self._chunks:
return b""
return self._chunks.pop(0)
class _RecordingFile:
def __init__(self) -> None:
self.writes: list[bytes] = []
def write(self, data: bytes) -> int:
self.writes.append(data)
return len(data)
@pytest.mark.asyncio
async def test_stream_to_file_batches_multiple_chunks_per_write(monkeypatch):
monkeypatch.setattr(io_module, "_DOWNLOAD_READ_CHUNK_SIZE", 4)
monkeypatch.setattr(io_module, "_DOWNLOAD_FLUSH_THRESHOLD", 10)
stream = _DummyStream([b"aaaa", b"bbbb", b"cccc"])
file_obj = _RecordingFile()
await _stream_to_file(
stream,
file_obj,
total_size=12,
start_time=0.0,
show_progress=False,
)
assert len(file_obj.writes) == 1
assert file_obj.writes[0] == b"aaaabbbbcccc"
+178
View File
@@ -0,0 +1,178 @@
import base64
import os
from pathlib import Path
import pytest
from aiohttp import web
from astrbot.core.message import components as components_module
from astrbot.core.message.components import File, Image, Record
from tests.fixtures.helpers import get_bound_tcp_port
@pytest.mark.asyncio
async def test_image_convert_to_file_path_returns_absolute_path(tmp_path):
file_path = tmp_path / "img.bin"
file_path.write_bytes(b"img")
image = Image(file=str(file_path))
resolved = await image.convert_to_file_path()
assert resolved == os.path.abspath(str(file_path))
@pytest.mark.asyncio
async def test_record_convert_to_file_path_returns_absolute_path(tmp_path):
file_path = tmp_path / "record.bin"
file_path.write_bytes(b"record")
record = Record(file=str(file_path))
resolved = await record.convert_to_file_path()
assert resolved == os.path.abspath(str(file_path))
@pytest.mark.asyncio
async def test_file_component_get_file_returns_absolute_path(tmp_path):
file_path = tmp_path / "file.bin"
file_path.write_bytes(b"file")
file_component = File(name="file.bin", file=str(file_path))
resolved = await file_component.get_file()
assert resolved == os.path.abspath(str(file_path))
@pytest.mark.asyncio
async def test_image_convert_to_base64_raises_on_missing_file(tmp_path):
image = Image(file=str(tmp_path / "missing.bin"))
with pytest.raises(Exception, match="not a valid file"):
await image.convert_to_base64()
@pytest.mark.asyncio
async def test_record_convert_to_base64_raises_on_missing_file(tmp_path):
record = Record(file=str(tmp_path / "missing.bin"))
with pytest.raises(Exception, match="not a valid file"):
await record.convert_to_base64()
@pytest.mark.asyncio
async def test_image_convert_to_base64_reads_existing_local_file(tmp_path):
raw = b"image-bytes"
file_path = tmp_path / "exists_image.bin"
file_path.write_bytes(raw)
image = Image(file=str(file_path))
encoded = await image.convert_to_base64()
assert base64.b64decode(encoded) == raw
@pytest.mark.asyncio
async def test_record_convert_to_base64_reads_existing_local_file(tmp_path):
raw = b"record-bytes"
file_path = tmp_path / "exists_record.bin"
file_path.write_bytes(raw)
record = Record(file=str(file_path))
encoded = await record.convert_to_base64()
assert base64.b64decode(encoded) == raw
@pytest.mark.asyncio
async def test_image_convert_to_base64_maps_permission_error(monkeypatch):
async def _raise_permission_error(_path: str) -> str:
raise PermissionError("permission denied")
monkeypatch.setattr(components_module, "file_to_base64", _raise_permission_error)
image = Image(file="/tmp/forbidden-image")
with pytest.raises(Exception, match="not a valid file"):
await image.convert_to_base64()
@pytest.mark.asyncio
async def test_record_convert_to_base64_maps_permission_error(monkeypatch):
async def _raise_permission_error(_path: str) -> str:
raise PermissionError("permission denied")
monkeypatch.setattr(components_module, "file_to_base64", _raise_permission_error)
record = Record(file="/tmp/forbidden-record")
with pytest.raises(Exception, match="not a valid file"):
await record.convert_to_base64()
@pytest.mark.asyncio
async def test_image_convert_to_file_path_from_base64_creates_absolute_file():
payload = b"image-base64-payload"
image = Image(file=f"base64://{base64.b64encode(payload).decode()}")
resolved = await image.convert_to_file_path()
resolved_path = Path(resolved)
assert resolved_path.is_absolute()
assert resolved_path.exists()
assert resolved_path.read_bytes() == payload
@pytest.mark.asyncio
async def test_record_convert_to_file_path_from_base64_creates_absolute_file():
payload = b"record-base64-payload"
record = Record(file=f"base64://{base64.b64encode(payload).decode()}")
resolved = await record.convert_to_file_path()
resolved_path = Path(resolved)
assert resolved_path.is_absolute()
assert resolved_path.exists()
assert resolved_path.read_bytes() == payload
async def _serve_payload(payload: bytes, route: str):
async def handle(_request):
return web.Response(body=payload)
app = web.Application()
app.router.add_get(route, handle)
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, get_bound_tcp_port(site)
@pytest.mark.asyncio
async def test_image_convert_to_file_path_from_http_creates_absolute_file():
payload = b"image-http-payload"
runner, port = await _serve_payload(payload, "/img.bin")
try:
image = Image(file=f"http://127.0.0.1:{port}/img.bin")
resolved = await image.convert_to_file_path()
resolved_path = Path(resolved)
assert resolved_path.is_absolute()
assert resolved_path.exists()
assert resolved_path.read_bytes() == payload
finally:
await runner.cleanup()
@pytest.mark.asyncio
async def test_record_convert_to_file_path_from_http_creates_absolute_file():
payload = b"record-http-payload"
runner, port = await _serve_payload(payload, "/record.bin")
try:
record = Record(file=f"http://127.0.0.1:{port}/record.bin")
resolved = await record.convert_to_file_path()
resolved_path = Path(resolved)
assert resolved_path.is_absolute()
assert resolved_path.exists()
assert resolved_path.read_bytes() == payload
finally:
await runner.cleanup()