Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 447b4542d1 | |||
| ead10b5643 | |||
| 6beca2144c | |||
| 2d27bfb6d0 | |||
| 3d1c3946f6 | |||
| cd434c5fed | |||
| 9683abeb19 | |||
| ab96537308 | |||
| 78fa58714c | |||
| 9afe5757be | |||
| bbc8c62d43 | |||
| a9c16febf4 |
@@ -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
|
||||
|
||||
@@ -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 ☁️:
|
||||
|
||||
[](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.
|
||||
|
||||
[](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
@@ -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 ☁️ :
|
||||
|
||||
[](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.
|
||||
|
||||
[](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
@@ -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 をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
|
||||
[](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 デプロイはコミュニティ提供の方式で、オンラインデモや軽量な試用に向いています。
|
||||
|
||||
[](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
@@ -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 ☁️:
|
||||
|
||||
[](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 поддерживается сообществом и подходит для онлайн-демо и лёгких тестовых запусков.
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
[](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 部署由社群維護,適合線上示範與輕量試用情境。
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
[](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 部署由社区维护,适合在线演示和轻量试用场景。
|
||||
|
||||
[](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 @@
|
||||
__version__ = "4.18.3"
|
||||
__version__ = "4.19.1"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+20
@@ -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")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 平台配置工厂
|
||||
# ============================================================
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 类测试"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user