Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter b781c83faa feat: enhance chat interface and mobile responsiveness 2026-03-02 12:26:13 +08:00
349 changed files with 1492 additions and 31732 deletions
-43
View File
@@ -1,43 +0,0 @@
name: release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@master
- name: nodejs installation
uses: actions/setup-node@v6
with:
node-version: "18"
- name: npm install
run: npm add -D vitepress
working-directory: './docs' # working-directory 指定 shell 命令运行目录
- name: npm run build
run: npm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
script: |
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /tmp/docs/
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: dist-without-markdown
path: |
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
+3 -36
View File
@@ -71,7 +71,7 @@ jobs:
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
- name: Upload dashboard artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: Dashboard-${{ steps.tag.outputs.tag }}
if-no-files-found: error
@@ -132,7 +132,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Download dashboard artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
@@ -184,8 +184,7 @@ 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
@@ -193,36 +192,6 @@ 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:
@@ -234,8 +203,6 @@ 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
-68
View File
@@ -1,68 +0,0 @@
name: sync wiki
on:
workflow_dispatch:
push:
branches:
- master
paths:
- '.github/workflows/sync-wiki.yml'
- 'docs/scripts/sync_docs_to_wiki.py'
- 'docs/tests/test_sync_docs_to_wiki.py'
- 'docs/zh/**'
- 'docs/en/**'
concurrency:
group: sync-wiki-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate manual ref
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
run: |
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
exit 1
- name: Check out docs repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Run sync unit tests
working-directory: docs
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
- name: Validate internal doc links
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
- name: Clone AstrBot wiki
env:
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
run: |
test -n "$WIKI_TOKEN"
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
- name: Generate wiki pages
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
- name: Commit and push wiki changes
working-directory: wiki
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
if git diff --cached --quiet; then
echo "No wiki changes to push"
exit 0
fi
git commit -m "docs: sync wiki from AstrBot-1/docs"
git push
-5
View File
@@ -36,9 +36,6 @@ dashboard/dist/
package-lock.json
yarn.lock
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
astrbot/dashboard/dist/
# Operating System
**/.DS_Store
.DS_Store
@@ -61,5 +58,3 @@ GenieData/
.codex/
.opencode/
.kilocode/
.worktrees/
docs/plans/
+16 -39
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, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
For users who want to quickly experience AstrBot, we recommend using the one-click deployment method with `uv` ⚡️:
```bash
uv tool install astrbot
@@ -83,58 +83,47 @@ astrbot
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> [!NOTE]
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
Update `astrbot`:
```bash
uv tool upgrade astrbot
```
### Docker Deployment
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
For users who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
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 one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
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 ☁️:
[![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 Deployment
### Desktop Application (Tauri)
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
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.
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
### Launcher Deployment
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
### One-Click Launcher Deployment (AstrBot Launcher)
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
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.
### Deploy on Replit
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
Community-contributed deployment method.
[![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**
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`.
**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)
## Supported Messaging Platforms
@@ -195,13 +184,6 @@ Connect AstrBot to your favorite chat platform.
| Minimax TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
## ❤️ Sponsors
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ Contributing
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
@@ -220,22 +202,17 @@ pip install pre-commit
pre-commit install
```
## 🌍 Community
### QQ Groups
- Group 9: 1076659624 (New)
- Group 10: 1078079676 (New)
- Group 1: 322154837
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
- Developer Group: 975206796
### Discord Server
+16 -28
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 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` ⚡️ :
Pour les utilisateurs qui souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
```bash
uv tool install astrbot
@@ -83,58 +83,47 @@ astrbot
> [uv](https://docs.astral.sh/uv/) doit être installé.
> [!NOTE]
> Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s).
Mettre à jour `astrbot` :
```bash
uv tool upgrade astrbot
```
### Déploiement Docker
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.
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.
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 eux-mêmes, 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, 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)
### Déploiement de l'application de bureau
### Application de bureau (Tauri)
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
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.
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
### Déploiement avec le lanceur
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.
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
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.
### Déployer sur Replit
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
Méthode de déploiement contribuée par la communauté.
[![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**
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`.
**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)
## Plateformes de messagerie prises en charge
@@ -222,7 +211,6 @@ pre-commit install
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811
### Serveur Discord
+15 -27
View File
@@ -73,7 +73,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
### ワンクリックデプロイ
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` ワンクリックデプロイをおすすめします ⚡️:
AstrBot を素早く試したいユーザーは、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
```bash
uv tool install astrbot
@@ -83,58 +83,47 @@ astrbot
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
> [!NOTE]
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
`astrbot` の更新:
```bash
uv tool upgrade 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 を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします
デスクトップで AstrBot を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
### ランチャーのデプロイ
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
### ランチャーによるワンクリックデプロイ(AstrBot Launcher
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
### 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)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` とソースベースのフルカスタム導入)を参照してください。
**その他のデプロイ方法**[宝塔パネルデプロイ](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)
## サポートされているメッセージプラットフォーム
@@ -223,7 +212,6 @@ pre-commit install
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
- 開発者群(正式): 1039761811
### Discord サーバー
+16 -28
View File
@@ -73,7 +73,7 @@ AstrBot — это универсальная платформа Agent-чатб
### Развёртывание в один клик
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
```bash
uv tool install astrbot
@@ -83,58 +83,47 @@ astrbot
> Требуется установленный [uv](https://docs.astral.sh/uv/).
> [!NOTE]
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
Обновить `astrbot`:
```bash
uv tool upgrade 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 на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
Для пользователей, которые хотят использовать AstrBot на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
### Развёртывание через лаунчер
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
### Установка в один клик через лаунчер (AstrBot Launcher)
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
### Развёртывание на 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) (установка через 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`).
**Другие способы развёртывания**: [Развёртывание 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)
## Поддерживаемые платформы обмена сообщениями
@@ -222,7 +211,6 @@ pre-commit install
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811
### Сервер Discord
+16 -32
View File
@@ -73,7 +73,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
### 一鍵部署
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️
```bash
uv tool install astrbot
@@ -83,20 +83,11 @@ astrbot
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
> [!NOTE]
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
更新 `astrbot`
```bash
uv tool upgrade 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)。
### 在雨雲上部署
@@ -104,37 +95,35 @@ uv tool upgrade 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、以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
### 啟動器部署
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher
### 啟動器一鍵部署(AstrBot Launcher
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
一個快速部署和多開方案,實現環境隔離。
### 在 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)(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` 的完整自訂安裝)。
**更多部署方式**[寶塔面板](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)
## 支援的訊息平台
@@ -217,16 +206,11 @@ pre-commit install
### QQ 群組
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群(闲聊吹水):975206796
- 開發者群(正式):1039761811
- 開發者群:975206796
### Discord 群組
+16 -30
View File
@@ -73,7 +73,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
### 一键部署
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️
```bash
uv tool install astrbot
@@ -83,20 +83,11 @@ astrbot
> 需要安装 [uv](https://docs.astral.sh/uv/)。
> [!NOTE]
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
更新 `astrbot`
```bash
uv tool upgrade 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)
### 在 雨云 上部署
@@ -104,37 +95,35 @@ uv tool upgrade 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、以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
### 启动器部署
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher
### 启动器一键部署(AstrBot Launcher
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
一个快速部署和多开方案,实现环境隔离。
### 在 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)(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` 的完整自定义安装)。
**更多部署方式**[宝塔面板](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)
## 支持的消息平台
@@ -218,16 +207,13 @@ pre-commit install
### QQ 群组
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群(偏闲聊吹水)975206796
- 开发者群(正式):1039761811
- 开发者群:975206796
### Discord 频道
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.19.5"
__version__ = "4.18.3"
+7 -7
View File
@@ -1,4 +1,4 @@
"""AstrBot CLI entry point"""
"""AstrBot CLI入口"""
import sys
@@ -29,23 +29,23 @@ def cli() -> None:
@click.command()
@click.argument("command_name", required=False, type=str)
def help(command_name: str | None) -> None:
"""Display help information for commands
"""显示命令的帮助信息
If COMMAND_NAME is provided, display detailed help for that command.
Otherwise, display general help information.
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
否则,显示通用帮助信息。
"""
ctx = click.get_current_context()
if command_name:
# Find the specified command
# 查找指定命令
command = cli.get_command(ctx, command_name)
if command:
# Display help for the specific command
# 显示特定命令的帮助信息
click.echo(command.get_help(ctx))
else:
click.echo(f"Unknown command: {command_name}")
sys.exit(1)
else:
# Display general help information
# 显示通用帮助信息
click.echo(cli.get_help(ctx))
+43 -47
View File
@@ -10,61 +10,57 @@ from ..utils import check_astrbot_root, get_astrbot_root
def _validate_log_level(value: str) -> str:
"""Validate log level"""
"""验证日志级别"""
value = value.upper()
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
raise click.ClickException(
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
)
return value
def _validate_dashboard_port(value: str) -> int:
"""Validate Dashboard port"""
"""验证 Dashboard 端口"""
try:
port = int(value)
if port < 1 or port > 65535:
raise click.ClickException("Port must be in range 1-65535")
raise click.ClickException("端口必须在 1-65535 范围内")
return port
except ValueError:
raise click.ClickException("Port must be a number")
raise click.ClickException("端口必须是数字")
def _validate_dashboard_username(value: str) -> str:
"""Validate Dashboard username"""
"""验证 Dashboard 用户名"""
if not value:
raise click.ClickException("Username cannot be empty")
raise click.ClickException("用户名不能为空")
return value
def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
"""验证 Dashboard 密码"""
if not value:
raise click.ClickException("Password cannot be empty")
raise click.ClickException("密码不能为空")
return hashlib.md5(value.encode()).hexdigest()
def _validate_timezone(value: str) -> str:
"""Validate timezone"""
"""验证时区"""
try:
zoneinfo.ZoneInfo(value)
except Exception:
raise click.ClickException(
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
)
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
return value
def _validate_callback_api_base(value: str) -> str:
"""Validate callback API base URL"""
"""验证回调接口基址"""
if not value.startswith("http://") and not value.startswith("https://"):
raise click.ClickException(
"Callback API base must start with http:// or https://"
)
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
return value
# Configuration items settable via CLI, mapping config keys to validator functions
# 可通过CLI设置的配置项,配置键到验证器函数的映射
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
"timezone": _validate_timezone,
"log_level": _validate_log_level,
@@ -76,11 +72,11 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
def _load_config() -> dict[str, Any]:
"""Load or initialize config file"""
"""加载或初始化配置文件"""
root = get_astrbot_root()
if not check_astrbot_root(root):
raise click.ClickException(
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
)
config_path = root / "data" / "cmd_config.json"
@@ -95,11 +91,11 @@ def _load_config() -> dict[str, Any]:
try:
return json.loads(config_path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError as e:
raise click.ClickException(f"Failed to parse config file: {e!s}")
raise click.ClickException(f"配置文件解析失败: {e!s}")
def _save_config(config: dict[str, Any]) -> None:
"""Save config file"""
"""保存配置文件"""
config_path = get_astrbot_root() / "data" / "cmd_config.json"
config_path.write_text(
@@ -109,21 +105,21 @@ def _save_config(config: dict[str, Any]) -> None:
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
"""Set a value in a nested dictionary"""
"""设置嵌套字典中的值"""
parts = path.split(".")
for part in parts[:-1]:
if part not in obj:
obj[part] = {}
elif not isinstance(obj[part], dict):
raise click.ClickException(
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
)
obj = obj[part]
obj[parts[-1]] = value
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
"""Get a value from a nested dictionary"""
"""获取嵌套字典中的值"""
parts = path.split(".")
for part in parts:
obj = obj[part]
@@ -132,21 +128,21 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
@click.group(name="conf")
def conf() -> None:
"""Configuration management commands
"""配置管理命令
Supported config keys:
支持的配置项:
- timezone: Timezone setting (e.g. Asia/Shanghai)
- timezone: 时区设置 (例如: Asia/Shanghai)
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
- dashboard.port: Dashboard port
- dashboard.port: Dashboard 端口
- dashboard.username: Dashboard username
- dashboard.username: Dashboard 用户名
- dashboard.password: Dashboard password
- dashboard.password: Dashboard 密码
- callback_api_base: Callback API base URL
- callback_api_base: 回调接口基址
"""
@@ -154,9 +150,9 @@ def conf() -> None:
@click.argument("key")
@click.argument("value")
def set_config(key: str, value: str) -> None:
"""Set the value of a config item"""
"""设置配置项的值"""
if key not in CONFIG_VALIDATORS:
raise click.ClickException(f"Unsupported config key: {key}")
raise click.ClickException(f"不支持的配置项: {key}")
config = _load_config()
@@ -166,29 +162,29 @@ def set_config(key: str, value: str) -> None:
_set_nested_item(config, key, validated_value)
_save_config(config)
click.echo(f"Config updated: {key}")
click.echo(f"配置已更新: {key}")
if key == "dashboard.password":
click.echo(" Old value: ********")
click.echo(" New value: ********")
click.echo(" 原值: ********")
click.echo(" 新值: ********")
else:
click.echo(f" Old value: {old_value}")
click.echo(f" New value: {validated_value}")
click.echo(f" 原值: {old_value}")
click.echo(f" 新值: {validated_value}")
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
raise click.ClickException(f"未知的配置项: {key}")
except Exception as e:
raise click.UsageError(f"Failed to set config: {e!s}")
raise click.UsageError(f"设置配置失败: {e!s}")
@conf.command(name="get")
@click.argument("key", required=False)
def get_config(key: str | None = None) -> None:
"""Get the value of a config item. If no key is provided, show all configurable items"""
"""获取配置项的值,不提供key则显示所有可配置项"""
config = _load_config()
if key:
if key not in CONFIG_VALIDATORS:
raise click.ClickException(f"Unsupported config key: {key}")
raise click.ClickException(f"不支持的配置项: {key}")
try:
value = _get_nested_item(config, key)
@@ -196,11 +192,11 @@ def get_config(key: str | None = None) -> None:
value = "********"
click.echo(f"{key}: {value}")
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
raise click.ClickException(f"未知的配置项: {key}")
except Exception as e:
raise click.UsageError(f"Failed to get config: {e!s}")
raise click.UsageError(f"获取配置失败: {e!s}")
else:
click.echo("Current config:")
click.echo("当前配置:")
for key in CONFIG_VALIDATORS:
try:
value = (
+9 -8
View File
@@ -8,12 +8,16 @@ from ..utils import check_dashboard, get_astrbot_root
async def initialize_astrbot(astrbot_root: Path) -> None:
"""Execute AstrBot initialization logic"""
"""执行 AstrBot 初始化逻辑"""
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
click.echo(f"Current Directory: {astrbot_root}")
click.echo(
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
)
if click.confirm(
f"Install AstrBot to this directory? {astrbot_root}",
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
default=True,
abort=True,
):
@@ -36,7 +40,7 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
@click.command()
def init() -> None:
"""Initialize AstrBot"""
"""初始化 AstrBot"""
click.echo("Initializing AstrBot...")
astrbot_root = get_astrbot_root()
lock_file = astrbot_root / "astrbot.lock"
@@ -45,11 +49,8 @@ def init() -> None:
try:
with lock.acquire():
asyncio.run(initialize_astrbot(astrbot_root))
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
)
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}")
raise click.ClickException(f"初始化失败: {e!s}")
+46 -54
View File
@@ -16,14 +16,14 @@ from ..utils import (
@click.group()
def plug() -> None:
"""Plugin management"""
"""插件管理"""
def _get_data_path() -> Path:
base = get_astrbot_root()
if not check_astrbot_root(base):
raise click.ClickException(
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
)
return (base / "data").resolve()
@@ -32,9 +32,7 @@ def display_plugins(plugins, title=None, color=None) -> None:
if title:
click.echo(click.style(title, fg=color, bold=True))
click.echo(
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
)
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
click.echo("-" * 85)
for p in plugins:
@@ -48,30 +46,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
@plug.command()
@click.argument("name")
def new(name: str) -> None:
"""Create a new plugin"""
"""创建新插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins" / name
if plug_path.exists():
raise click.ClickException(f"Plugin {name} already exists")
raise click.ClickException(f"插件 {name} 已存在")
author = click.prompt("Enter plugin author", type=str)
desc = click.prompt("Enter plugin description", type=str)
version = click.prompt("Enter plugin version", type=str)
author = click.prompt("请输入插件作者", type=str)
desc = click.prompt("请输入插件描述", type=str)
version = click.prompt("请输入插件版本", type=str)
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
raise click.ClickException("Version must be in x.y or x.y.z format")
repo = click.prompt("Enter plugin repository URL:", type=str)
raise click.ClickException("版本号必须为 x.y x.y.z 格式")
repo = click.prompt("请输入插件仓库:", type=str)
if not repo.startswith("http"):
raise click.ClickException("Repository URL must start with http")
raise click.ClickException("仓库地址必须以 http 开头")
click.echo("Downloading plugin template...")
click.echo("下载插件模板...")
get_git_repo(
"https://github.com/Soulter/helloworld",
plug_path,
)
click.echo("Rewriting plugin metadata...")
# Rewrite metadata.yaml
click.echo("重写插件信息...")
# 重写 metadata.yaml
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
f.write(
f"name: {name}\n"
@@ -81,13 +79,11 @@ def new(name: str) -> None:
f"repo: {repo}\n",
)
# Rewrite README.md
# 重写 README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
)
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
# Rewrite main.py
# 重写 main.py
with open(plug_path / "main.py", encoding="utf-8") as f:
content = f.read()
@@ -99,54 +95,54 @@ def new(name: str) -> None:
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
f.write(new_content)
click.echo(f"Plugin {name} created successfully")
click.echo(f"插件 {name} 创建成功")
@plug.command()
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
def list(all: bool) -> None:
"""List plugins"""
"""列出插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
# Unpublished plugins
# 未发布的插件
not_published_plugins = [
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
]
if not_published_plugins:
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
display_plugins(not_published_plugins, "未发布的插件", "red")
# Plugins needing update
# 需要更新的插件
need_update_plugins = [
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
]
if need_update_plugins:
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
# Installed plugins
# 已安装的插件
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
if installed_plugins:
display_plugins(installed_plugins, "Installed Plugins", "green")
display_plugins(installed_plugins, "已安装的插件", "green")
# Uninstalled plugins
# 未安装的插件
not_installed_plugins = [
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
]
if not_installed_plugins and all:
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
display_plugins(not_installed_plugins, "未安装的插件", "blue")
if (
not any([not_published_plugins, need_update_plugins, installed_plugins])
and not all
):
click.echo("No plugins installed")
click.echo("未安装任何插件")
@plug.command()
@click.argument("name")
@click.option("--proxy", help="Proxy server address")
@click.option("--proxy", help="代理服务器地址")
def install(name: str, proxy: str | None) -> None:
"""Install a plugin"""
"""安装插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -161,7 +157,7 @@ def install(name: str, proxy: str | None) -> None:
)
if not plugin:
raise click.ClickException(f"Plugin {name} not found or already installed")
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
@@ -169,32 +165,30 @@ def install(name: str, proxy: str | None) -> None:
@plug.command()
@click.argument("name")
def remove(name: str) -> None:
"""Uninstall a plugin"""
"""卸载插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
plugin = next((p for p in plugins if p["name"] == name), None)
if not plugin or not plugin.get("local_path"):
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
raise click.ClickException(f"插件 {name} 不存在或未安装")
plugin_path = plugin["local_path"]
click.confirm(
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
)
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
try:
shutil.rmtree(plugin_path)
click.echo(f"Plugin {name} has been uninstalled")
click.echo(f"插件 {name} 已卸载")
except Exception as e:
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
@plug.command()
@click.argument("name", required=False)
@click.option("--proxy", help="GitHub proxy address")
@click.option("--proxy", help="Github代理地址")
def update(name: str, proxy: str | None) -> None:
"""Update plugins"""
"""更新插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -210,9 +204,7 @@ def update(name: str, proxy: str | None) -> None:
)
if not plugin:
raise click.ClickException(
f"Plugin {name} does not need updating or cannot be updated"
)
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
else:
@@ -221,20 +213,20 @@ def update(name: str, proxy: str | None) -> None:
]
if not need_update_plugins:
click.echo("No plugins need updating")
click.echo("没有需要更新的插件")
return
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
for plugin in need_update_plugins:
plugin_name = plugin["name"]
click.echo(f"Updating plugin {plugin_name}...")
click.echo(f"正在更新插件 {plugin_name}...")
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@plug.command()
@click.argument("query")
def search(query: str) -> None:
"""Search for plugins"""
"""搜索插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
@@ -247,7 +239,7 @@ def search(query: str) -> None:
]
if not matched_plugins:
click.echo(f"No plugins matching '{query}' found")
click.echo(f"未找到匹配 '{query}' 的插件")
return
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
+9 -11
View File
@@ -11,7 +11,7 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
async def run_astrbot(astrbot_root: Path) -> None:
"""Run AstrBot"""
"""运行 AstrBot"""
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
@@ -26,18 +26,18 @@ async def run_astrbot(astrbot_root: Path) -> None:
await core_lifecycle.start()
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
@click.command()
def run(reload: bool, port: str) -> None:
"""Run AstrBot"""
"""运行 AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
astrbot_root = get_astrbot_root()
if not check_astrbot_root(astrbot_root):
raise click.ClickException(
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
@@ -47,7 +47,7 @@ def run(reload: bool, port: str) -> None:
os.environ["DASHBOARD_PORT"] = port
if reload:
click.echo("Plugin auto-reload enabled")
click.echo("启用插件自动重载")
os.environ["ASTRBOT_RELOAD"] = "1"
lock_file = astrbot_root / "astrbot.lock"
@@ -55,10 +55,8 @@ def run(reload: bool, port: str) -> None:
with lock.acquire():
asyncio.run(run_astrbot(astrbot_root))
except KeyboardInterrupt:
click.echo("AstrBot has been shut down.")
click.echo("AstrBot 已关闭...")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
)
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
except Exception as e:
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
+13 -21
View File
@@ -2,12 +2,9 @@ from pathlib import Path
import click
# Static assets bundled inside the installed wheel (built by hatch_build.py).
_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
def check_astrbot_root(path: str | Path) -> bool:
"""Check if the path is an AstrBot root directory"""
"""检查路径是否为 AstrBot 根目录"""
if not isinstance(path, Path):
path = Path(path)
if not path.exists() or not path.is_dir():
@@ -18,48 +15,43 @@ def check_astrbot_root(path: str | Path) -> bool:
def get_astrbot_root() -> Path:
"""Get the AstrBot root directory path"""
"""获取Astrbot根目录路径"""
return Path.cwd()
async def check_dashboard(astrbot_root: Path) -> None:
"""Check if the dashboard is installed"""
"""检查是否安装了dashboard"""
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from .version_comparator import VersionComparator
# If the wheel ships bundled dashboard assets, no network download is needed.
if _BUNDLED_DIST.exists():
click.echo("Dashboard is bundled with the package skipping download.")
return
try:
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo("Dashboard is not installed")
click.echo("未安装管理面板")
if click.confirm(
"Install dashboard?",
"是否安装管理面板?",
default=True,
abort=True,
):
click.echo("Installing dashboard...")
click.echo("正在安装管理面板...")
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
)
click.echo("Dashboard installed successfully")
click.echo("管理面板安装完成")
case str():
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
click.echo("Dashboard is already up to date")
click.echo("管理面板已是最新版本")
return
try:
version = dashboard_version.split("v")[1]
click.echo(f"Dashboard version: {version}")
click.echo(f"管理面板版本: {version}")
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root),
@@ -67,10 +59,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
latest=False,
)
except Exception as e:
click.echo(f"Failed to download dashboard: {e}")
click.echo(f"下载管理面板失败: {e}")
return
except FileNotFoundError:
click.echo("Initializing dashboard directory...")
click.echo("初始化管理面板目录...")
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"),
@@ -78,7 +70,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
version=f"v{VERSION}",
latest=False,
)
click.echo("Dashboard initialized successfully")
click.echo("管理面板初始化完成")
except Exception as e:
click.echo(f"Failed to download dashboard: {e}")
click.echo(f"下载管理面板失败: {e}")
return
+43 -47
View File
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
class PluginStatus(str, Enum):
INSTALLED = "installed"
NEED_UPDATE = "needs-update"
NOT_INSTALLED = "not-installed"
NOT_PUBLISHED = "unpublished"
INSTALLED = "已安装"
NEED_UPDATE = "需更新"
NOT_INSTALLED = "未安装"
NOT_PUBLISHED = "未发布"
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
"""Download code from a Git repository and extract to the specified path"""
"""从 Git 仓库下载代码并解压到指定路径"""
temp_dir = Path(tempfile.mkdtemp())
try:
# Parse repository info
# 解析仓库信息
repo_namespace = url.split("/")[-2:]
author = repo_namespace[0]
repo = repo_namespace[1]
# Try to get the latest release
# 尝试获取最新的 release
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
try:
with httpx.Client(
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
releases = resp.json()
if releases:
# Use the latest release
# 使用最新的 release
download_url = releases[0]["zipball_url"]
else:
# No release found, use default branch
click.echo(f"Downloading {author}/{repo} from default branch")
# 没有 release,使用默认分支
click.echo(f"正在从默认分支下载 {author}/{repo}")
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
except Exception as e:
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
download_url = url
# Apply proxy
# 应用代理
if proxy:
download_url = f"{proxy}/{download_url}"
# Download and extract
# 下载并解压
with httpx.Client(
proxy=proxy if proxy else None,
follow_redirects=True,
@@ -65,7 +65,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
and "archive/refs/heads/master.zip" in download_url
):
alt_url = download_url.replace("master.zip", "main.zip")
click.echo("Branch 'master' not found, trying 'main' branch")
click.echo("master 分支不存在,尝试下载 main 分支")
resp = client.get(alt_url)
resp.raise_for_status()
else:
@@ -84,13 +84,13 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
def load_yaml_metadata(plugin_dir: Path) -> dict:
"""Load plugin metadata from metadata.yaml file
""" metadata.yaml 文件加载插件元数据
Args:
plugin_dir: Plugin directory path
plugin_dir: 插件目录路径
Returns:
dict: Dictionary containing metadata, or empty dict if loading fails
dict: 包含元数据的字典,如果读取失败则返回空字典
"""
yaml_path = plugin_dir / "metadata.yaml"
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
try:
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
except Exception as e:
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
return {}
def build_plug_list(plugins_dir: Path) -> list:
"""Build plugin list containing local and online plugin information
"""构建插件列表,包含本地和在线插件信息
Args:
plugins_dir (Path): Plugin directory path
plugins_dir (Path): 插件目录路径
Returns:
list: List of dicts containing plugin information
list: 包含插件信息的字典列表
"""
# Get local plugin info
# 获取本地插件信息
result = []
if plugins_dir.exists():
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
plugin_dir = plugins_dir / plugin_name
# Load metadata from metadata.yaml
# metadata.yaml 加载元数据
metadata = load_yaml_metadata(plugin_dir)
if "desc" not in metadata and "description" in metadata:
metadata["desc"] = metadata["description"]
# If metadata loaded successfully, add to result list
# 如果成功加载元数据,添加到结果列表
if metadata and all(
k in metadata for k in ["name", "desc", "version", "author", "repo"]
):
@@ -140,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
},
)
# Get online plugin list
# 获取在线插件列表
online_plugins = []
try:
with httpx.Client() as client:
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
},
)
except Exception as e:
click.echo(f"Failed to get online plugin list: {e}", err=True)
click.echo(f"获取在线插件列表失败: {e}", err=True)
# Compare with online plugins and update status
# 与在线插件比对,更新状态
online_plugin_names = {plugin["name"] for plugin in online_plugins}
for local_plugin in result:
if local_plugin["name"] in online_plugin_names:
# Find the corresponding online plugin
# 查找对应的在线插件
online_plugin = next(
p for p in online_plugins if p["name"] == local_plugin["name"]
)
@@ -179,10 +179,10 @@ def build_plug_list(plugins_dir: Path) -> list:
):
local_plugin["status"] = PluginStatus.NEED_UPDATE
else:
# Local plugin is not published online
# 本地插件未在线上发布
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
# Add uninstalled online plugins
# 添加未安装的在线插件
for online_plugin in online_plugins:
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
result.append(online_plugin)
@@ -196,19 +196,19 @@ def manage_plugin(
is_update: bool = False,
proxy: str | None = None,
) -> None:
"""Install or update a plugin
"""安装或更新插件
Args:
plugin (dict): Plugin info dict
plugins_dir (Path): Plugins directory
is_update (bool, optional): Whether this is an update operation. Defaults to False
proxy (str, optional): Proxy server address
plugin (dict): 插件信息字典
plugins_dir (Path): 插件目录
is_update (bool, optional): 是否为更新操作. 默认为 False
proxy (str, optional): 代理服务器地址
"""
plugin_name = plugin["name"]
repo_url = plugin["repo"]
# If updating and local path exists, use it directly
# 如果是更新且有本地路径,直接使用本地路径
if is_update and plugin.get("local_path"):
target_path = Path(plugin["local_path"])
else:
@@ -216,13 +216,11 @@ def manage_plugin(
backup_path = Path(f"{target_path}_backup") if is_update else None
# Check if plugin exists
# 检查插件是否存在
if is_update and not target_path.exists():
raise click.ClickException(
f"Plugin {plugin_name} is not installed and cannot be updated"
)
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
# Backup existing plugin
# 备份现有插件
if is_update and backup_path is not None and backup_path.exists():
shutil.rmtree(backup_path)
if is_update and backup_path is not None:
@@ -230,21 +228,19 @@ def manage_plugin(
try:
click.echo(
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
)
get_git_repo(repo_url, target_path, proxy)
# Update succeeded, delete backup
# 更新成功,删除备份
if is_update and backup_path is not None and backup_path.exists():
shutil.rmtree(backup_path)
click.echo(
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
)
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
except Exception as e:
if target_path.exists():
shutil.rmtree(target_path, ignore_errors=True)
if is_update and backup_path is not None and backup_path.exists():
shutil.move(backup_path, target_path)
raise click.ClickException(
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
)
+11 -11
View File
@@ -1,4 +1,4 @@
"""Copied from astrbot.core.utils.version_comparator"""
"""拷贝自 astrbot.core.utils.version_comparator"""
import re
@@ -6,11 +6,11 @@ import re
class VersionComparator:
@staticmethod
def compare_version(v1: str, v2: str) -> int:
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
Reference: https://semver.org/
参考: https://semver.org/lang/zh-CN/
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2
"""
v1 = v1.lower().replace("v", "")
v2 = v2.lower().replace("v", "")
@@ -24,7 +24,7 @@ class VersionComparator:
return [], None
major_minor_patch = match.group(1).split(".")
prerelease = match.group(2)
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
parts = [int(x) for x in major_minor_patch]
prerelease = VersionComparator._split_prerelease(prerelease)
return parts, prerelease
@@ -32,7 +32,7 @@ class VersionComparator:
v1_parts, v1_prerelease = split_version(v1)
v2_parts, v2_prerelease = split_version(v2)
# Compare numeric parts
# 比较数字部分
length = max(len(v1_parts), len(v2_parts))
v1_parts.extend([0] * (length - len(v1_parts)))
v2_parts.extend([0] * (length - len(v2_parts)))
@@ -43,11 +43,11 @@ class VersionComparator:
if v1_parts[i] < v2_parts[i]:
return -1
# Compare pre-release tags
# 比较预发布标签
if v1_prerelease is None and v2_prerelease is not None:
return 1 # Version without pre-release tag is higher than one with it
return 1 # 没有预发布标签的版本高于有预发布标签的版本
if v1_prerelease is not None and v2_prerelease is None:
return -1 # Version with pre-release tag is lower than one without it
return -1 # 有预发布标签的版本低于没有预发布标签的版本
if v1_prerelease is not None and v2_prerelease is not None:
len_pre = max(len(v1_prerelease), len(v2_prerelease))
for i in range(len_pre):
@@ -72,9 +72,9 @@ class VersionComparator:
return 1
if p1 < p2:
return -1
return 0 # Pre-release tags are identical
return 0 # 预发布标签完全相同
return 0 # Both numeric parts and pre-release tags are equal
return 0 # 数字部分和预发布标签都相同
@staticmethod
def _split_prerelease(prerelease):
+2 -16
View File
@@ -4,21 +4,7 @@ from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import (
DependencyConflictError as DependencyConflictError,
)
from astrbot.core.utils.pip_installer import (
PipInstaller,
)
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements as find_missing_requirements,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
)
from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
@@ -28,7 +14,7 @@ from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
DEMO_MODE = os.getenv("DEMO_MODE", False)
astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
+6 -19
View File
@@ -144,14 +144,10 @@ class MCPClient:
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(
msg: str | mcp.types.LoggingMessageNotificationParams,
) -> None:
def logging_callback(msg: str) -> None:
# Handle MCP service error logs
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
if "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
@@ -218,24 +214,15 @@ class MCPClient:
**cfg,
)
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
def callback(msg: str) -> None:
# Handle MCP service error logs
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
if msg.level in (
"warning",
"error",
"critical",
"alert",
"emergency",
):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.INFO,
level=logging.ERROR,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
@@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
while True:
try:
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
None, response_queue.get, True, 1
)
except queue.Empty:
@@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
# 发起请求
partial = functools.partial(Application.call, **payload)
response = await asyncio.get_running_loop().run_in_executor(None, partial)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
async for resp in self._handle_streaming_response(response, session_id):
yield resp
+3 -5
View File
@@ -291,9 +291,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
except Exception:
continue
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
agent_max_step = int(prov_settings.get("max_agent_step", 30))
stream = prov_settings.get("streaming_response", False)
llm_resp = await ctx.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
@@ -302,8 +299,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
system_prompt=tool.agent.instructions,
tools=toolset,
contexts=contexts,
max_steps=agent_max_step,
stream=stream,
max_steps=30,
run_hooks=tool.agent.run_hooks,
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
)
yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
-2
View File
@@ -846,8 +846,6 @@ def _apply_sandbox_tools(
) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
if req.system_prompt is None:
req.system_prompt = ""
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
if booter == "shipyard":
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
+1 -14
View File
@@ -204,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": (
"Component type. One of: "
"plain, image, record, video, file, mention_user. Record is voice message."
"plain, image, record, file, mention_user"
),
},
"text": {
@@ -320,19 +320,6 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
components.append(Comp.Record.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for record component."
elif msg_type == "video":
path = msg.get("path")
url = msg.get("url")
if path:
(
local_path,
file_from_sandbox,
) = await self._resolve_path_from_sandbox(context, path)
components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Video.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for video component."
elif msg_type == "file":
path = msg.get("path")
url = msg.get("url")
+2 -3
View File
@@ -121,12 +121,11 @@ class BayContainerManager:
async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None:
"""Block until Bay's ``/health`` endpoint returns 200."""
url = f"http://127.0.0.1:{self._host_port}/health"
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
deadline = asyncio.get_event_loop().time() + timeout
last_error: str = ""
async with aiohttp.ClientSession() as session:
while loop.time() < deadline:
while asyncio.get_event_loop().time() < deadline:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=3)
+2 -7
View File
@@ -1,5 +1,4 @@
import json
import os
import shutil
import uuid
from pathlib import Path
@@ -42,6 +41,8 @@ def _discover_bay_credentials(endpoint: str) -> str:
Returns:
API key string, or empty string if not found.
"""
import os
candidates: list[Path] = []
# 1. BAY_DATA_DIR env var
@@ -422,12 +423,6 @@ async def get_booter(
) -> ComputerBooter:
config = context.get_config(umo=session_id)
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
if runtime == "local":
return get_local_booter()
elif runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+2 -8
View File
@@ -1,4 +1,3 @@
import platform
from dataclasses import dataclass, field
import mcp
@@ -11,8 +10,6 @@ from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.computer.tools.permissions import check_admin_permission
from astrbot.core.message.message_event_result import MessageChain
_OS_NAME = platform.system()
param_schema = {
"type": "object",
"properties": {
@@ -64,7 +61,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = f"Run codes in an IPython shell. Current OS: {_OS_NAME}."
description: str = "Run codes in an IPython shell."
parameters: dict = field(default_factory=lambda: param_schema)
async def call(
@@ -86,10 +83,7 @@ class PythonTool(FunctionTool):
@dataclass
class LocalPythonTool(FunctionTool):
name: str = "astrbot_execute_python"
description: str = (
f"Execute codes in a Python environment. Current OS: {_OS_NAME}. "
"Use system-compatible commands."
)
description: str = "Execute codes in a Python environment."
parameters: dict = field(default_factory=lambda: param_schema)
+48 -186
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.19.5"
VERSION = "4.18.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -219,9 +219,6 @@ DEFAULT_CONFIG = {
"telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
},
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
},
"wake_prefix": ["/"],
"log_level": "INFO",
@@ -345,20 +342,14 @@ CONFIG_METADATA_2 = {
"企业微信智能机器人": {
"id": "wecom_ai_bot",
"type": "wecom_ai_bot",
"hint": "如果发现字段有异常,请重新创建",
"enable": True,
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
"wecom_ai_bot_name": "",
"wecomaibot_ws_bot_id": "",
"wecomaibot_ws_secret": "",
"wecomaibot_token": "",
"wecomaibot_encoding_aes_key": "",
"wecomaibot_init_respond_text": "",
"wecomaibot_friend_message_welcome_text": "",
"wecom_ai_bot_name": "",
"msg_push_webhook_url": "",
"only_use_webhook_url_to_send": False,
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
"wecomaibot_heartbeat_interval": 30,
"token": "",
"encoding_aes_key": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
"callback_server_host": "0.0.0.0",
@@ -404,6 +395,7 @@ CONFIG_METADATA_2 = {
"discord_token": "",
"discord_proxy": "",
"discord_command_register": True,
"discord_guild_id_for_debug": "",
"discord_activity_name": "",
},
"Misskey": {
@@ -458,20 +450,6 @@ CONFIG_METADATA_2 = {
"satori_heartbeat_interval": 10,
"satori_reconnect_delay": 5,
},
"kook": {
"id": "kook",
"type": "kook",
"enable": False,
"kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60,
"kook_heartbeat_interval": 30,
"kook_heartbeat_timeout": 6,
"kook_max_heartbeat_failures": 3,
"kook_max_consecutive_failures": 5,
},
# "WebChat": {
# "id": "webchat",
# "type": "webchat",
@@ -741,13 +719,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "请务必填写正确,否则无法使用一些指令。",
},
"wecom_ai_bot_connection_mode": {
"description": "企业微信智能机器人连接模式",
"type": "string",
"options": ["webhook", "long_connection"],
"labels": ["Webhook 回调", "长连接"],
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey。长连接模式需要配置 BotID/Secret。",
},
"wecomaibot_init_respond_text": {
"description": "企业微信智能机器人初始响应文本",
"type": "string",
@@ -758,22 +729,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
},
"wecomaibot_token": {
"description": "企业微信智能机器人 Token",
"type": "string",
"hint": "用于 Webhook 回调模式的身份验证。",
"condition": {
"wecom_ai_bot_connection_mode": "webhook",
},
},
"wecomaibot_encoding_aes_key": {
"description": "企业微信智能机器人 EncodingAESKey",
"type": "string",
"hint": "用于 Webhook 回调模式的消息加密解密。",
"condition": {
"wecom_ai_bot_connection_mode": "webhook",
},
},
"msg_push_webhook_url": {
"description": "企业微信消息推送 Webhook URL",
"type": "string",
@@ -784,40 +739,6 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
},
"wecomaibot_ws_bot_id": {
"description": "长连接 BotID",
"type": "string",
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"wecomaibot_ws_secret": {
"description": "长连接 Secret",
"type": "string",
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"wecomaibot_ws_url": {
"description": "长连接 WebSocket 地址",
"type": "string",
"invisible": True,
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"wecomaibot_heartbeat_interval": {
"description": "长连接心跳间隔",
"type": "int",
"invisible": True,
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"lark_bot_name": {
"description": "飞书机器人的名字",
"type": "string",
@@ -834,8 +755,7 @@ CONFIG_METADATA_2 = {
"hint": "可选的代理地址:http://ip:port",
},
"discord_command_register": {
"description": "注册 Discord 指令",
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令",
"description": "是否自动将插件指令注册 Discord 斜杠指令",
"type": "bool",
},
"discord_activity_name": {
@@ -862,7 +782,7 @@ CONFIG_METADATA_2 = {
"unified_webhook_mode": {
"description": "统一 Webhook 模式",
"type": "bool",
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}",
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}",
},
"webhook_uuid": {
"invisible": True,
@@ -870,51 +790,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。",
},
"kook_bot_token": {
"description": "机器人 Token",
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
"hint": "重连延迟时间(秒),使用指数退避策略。",
},
"kook_max_reconnect_delay": {
"description": "最大重连延迟",
"type": "int",
"hint": "重连延迟的最大值(秒)。",
},
"kook_max_retry_delay": {
"description": "最大重试延迟",
"type": "int",
"hint": "重试的最大延迟时间(秒)。",
},
"kook_heartbeat_interval": {
"description": "心跳间隔",
"type": "int",
"hint": "心跳检测间隔时间(秒)。",
},
"kook_heartbeat_timeout": {
"description": "心跳超时时间",
"type": "int",
"hint": "心跳检测超时时间(秒)。",
},
"kook_max_heartbeat_failures": {
"description": "最大心跳失败次数",
"type": "int",
"hint": "允许的最大心跳失败次数,超过后断开连接。",
},
"kook_max_consecutive_failures": {
"description": "最大连续失败次数",
"type": "int",
"hint": "允许的最大连续失败次数,超过后停止重试。",
},
},
},
"platform_settings": {
@@ -1189,7 +1064,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://openrouter.ai/api/v1",
"api_base": "https://openrouter.ai/v1",
"proxy": "",
"custom_headers": {},
},
@@ -3277,6 +3152,46 @@ CONFIG_METADATA_3 = {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.max_quoted_fallback_images": {
"description": "引用图片回退解析上限",
"type": "int",
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_component_chain_depth": {
"description": "引用解析组件链深度",
"type": "int",
"hint": "解析 Reply 组件链时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_forward_node_depth": {
"description": "引用解析转发节点深度",
"type": "int",
"hint": "解析合并转发节点时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_forward_fetch": {
"description": "引用解析转发拉取上限",
"type": "int",
"hint": "递归拉取 get_forward_msg 的最大次数。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.warn_on_action_failure": {
"description": "引用解析 action 失败告警",
"type": "bool",
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.max_agent_step": {
"description": "工具调用轮数上限",
"type": "int",
@@ -3320,46 +3235,6 @@ CONFIG_METADATA_3 = {
"type": "bool",
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
},
"provider_settings.max_quoted_fallback_images": {
"description": "引用图片回退解析上限",
"type": "int",
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_component_chain_depth": {
"description": "引用解析组件链深度",
"type": "int",
"hint": "解析 Reply 组件链时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_forward_node_depth": {
"description": "引用解析转发节点深度",
"type": "int",
"hint": "解析合并转发节点时允许的最大递归深度。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.max_forward_fetch": {
"description": "引用解析转发拉取上限",
"type": "int",
"hint": "递归拉取 get_forward_msg 的最大次数。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.quoted_message_parser.warn_on_action_failure": {
"description": "引用解析 action 失败告警",
"type": "bool",
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
},
"condition": {
"provider_settings.enable": True,
@@ -3571,19 +3446,6 @@ CONFIG_METADATA_3 = {
"platform_specific.telegram.pre_ack_emoji.enable": True,
},
},
"platform_specific.discord.pre_ack_emoji.enable": {
"description": "[Discord] 启用预回应表情",
"type": "bool",
},
"platform_specific.discord.pre_ack_emoji.emojis": {
"description": "表情列表(Unicode 或自定义表情名)",
"type": "list",
"items": {"type": "string"},
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳",
"condition": {
"platform_specific.discord.pre_ack_emoji.enable": True,
},
},
},
},
},
-4
View File
@@ -175,10 +175,6 @@ class LogManager:
_trace_sink_id: int | None = None
_NOISY_LOGGER_LEVELS: dict[str, int] = {
"aiosqlite": logging.WARNING,
"filelock": logging.WARNING,
"asyncio": logging.WARNING,
"tzlocal": logging.WARNING,
"apscheduler": logging.WARNING,
}
@classmethod
+18 -44
View File
@@ -539,36 +539,13 @@ class Reply(BaseMessageComponent):
class Poke(BaseMessageComponent):
type: ComponentType = ComponentType.Poke
_type: str | int = "126"
id: int | str | None = 0
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
type: str = ComponentType.Poke
id: int | None = 0
qq: int | None = 0
def __init__(self, poke_type: str | int | None = None, **_) -> None:
# Backward compatible with old signature: Poke(type="poke", ...)
legacy_type = _.pop("type", None)
if poke_type is None:
poke_type = legacy_type
if poke_type in (None, "", "poke", "Poke"):
poke_type = "126"
super().__init__(_type=str(poke_type), **_)
def target_id(self) -> str | None:
"""Return normalized target id, compatible with old `qq` field."""
for value in (self.id, self.qq):
if value is None:
continue
text = str(value).strip()
if text and text != "0":
return text
return None
def toDict(self):
target_id = self.target_id()
data = {"type": str(self._type or "126")}
if target_id:
data["id"] = target_id
return {"type": "poke", "data": data}
def __init__(self, type: str, **_) -> None:
type = f"Poke:{type}"
super().__init__(type=type, **_)
class Forward(BaseMessageComponent):
@@ -699,24 +676,21 @@ class File(BaseMessageComponent):
if self.url:
try:
# 检查是否有正在运行的 event loop
asyncio.get_running_loop()
logger.warning(
"不可以在异步上下文中同步等待下载! "
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
"请使用 await get_file() 代替直接获取 <File>.file 字段",
)
return ""
except RuntimeError:
# 没有运行中的 event loop,可以同步执行
try:
# 使用 asyncio.run 安全地创建和关闭事件循环
asyncio.run(self._download_file())
except Exception:
logger.exception("文件下载失败")
loop = asyncio.get_event_loop()
if loop.is_running():
logger.warning(
"不可以在异步上下文中同步等待下载! "
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
"请使用 await get_file() 代替直接获取 <File>.file 字段",
)
return ""
# 等待下载完成
loop.run_until_complete(self._download_file())
if self.file_ and os.path.exists(self.file_):
return os.path.abspath(self.file_)
except Exception as e:
logger.error(f"文件下载失败: {e}")
return ""
@@ -27,7 +27,7 @@ class PreProcessStage(Stage):
) -> None | AsyncGenerator[None, None]:
"""在处理事件之前的预处理"""
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
supported = {"telegram", "lark", "discord"}
supported = {"telegram", "lark"}
platform = event.get_platform_name()
cfg = (
self.config.get("platform_specific", {})
+1 -1
View File
@@ -28,7 +28,7 @@ class RespondStage(Stage):
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
Comp.Node: lambda comp: bool(comp.content), # 转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
@@ -5,7 +5,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -383,11 +383,8 @@ class ResultDecorateStage(Stage):
)
result.chain = [node]
# at 回复 / 引用回复仅适用于纯文本或图文消息
can_decorate = all(
isinstance(item, (Plain, Image)) for item in result.chain
)
if can_decorate:
has_plain = any(isinstance(item, Plain) for item in result.chain)
if has_plain:
# at 回复
if (
self.reply_with_mention
@@ -402,4 +399,5 @@ class ResultDecorateStage(Stage):
# 引用回复
if self.reply_with_quote:
result.chain.insert(0, Reply(id=event.message_obj.message_id))
if not any(isinstance(item, File) for item in result.chain):
result.chain.insert(0, Reply(id=event.message_obj.message_id))
-4
View File
@@ -180,10 +180,6 @@ class PlatformManager:
from .sources.line.line_adapter import (
LinePlatformAdapter, # noqa: F401
)
case "kook":
from .sources.kook.kook_adapter import (
KookPlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
if "sub_type" in event:
if event["sub_type"] == "poke" and "target_id" in event:
abm.message.append(Poke(id=str(event["target_id"])))
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
return abm
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, Image, Plain, Record, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -178,110 +178,29 @@ class DingtalkPlatformAdapter(Platform):
abm.session_id = abm.sender.user_id
message_type: str = cast(str, message.message_type)
robot_code = cast(str, message.robot_code or "")
raw_content = cast(dict, message.extensions.get("content") or {})
if not isinstance(raw_content, dict):
raw_content = {}
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
case "picture":
if not robot_code:
logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode")
await self._remember_sender_binding(message, abm)
return abm
image_content = cast(
dingtalk_stream.ImageContent | None,
message.image_content,
)
download_code = cast(
str, (image_content.download_code if image_content else "") or ""
)
if not download_code:
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
else:
f_path = await self.download_ding_file(
download_code,
robot_code,
"jpg",
)
if f_path:
abm.message.append(Image.fromFileSystem(f_path))
else:
logger.warning("钉钉图片消息下载失败,无法解析为图片")
case "richText":
rtc: dingtalk_stream.RichTextContent = cast(
dingtalk_stream.RichTextContent, message.rich_text_content
)
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
plain_parts: list[str] = []
for content in contents:
plains = ""
if "text" in content:
plain_text = cast(str, content.get("text") or "")
if plain_text:
plain_parts.append(plain_text)
abm.message.append(Plain(plain_text))
plains += content["text"]
abm.message.append(Plain(plains))
elif "type" in content and content["type"] == "picture":
download_code = cast(str, content.get("downloadCode") or "")
if not download_code:
logger.warning(
"钉钉富文本图片消息缺少 downloadCode,已跳过"
)
continue
if not robot_code:
logger.error(
"钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
)
continue
f_path = await self.download_ding_file(
download_code,
robot_code,
content["downloadCode"],
cast(str, message.robot_code),
"jpg",
)
if f_path:
abm.message.append(Image.fromFileSystem(f_path))
abm.message_str = "".join(plain_parts).strip()
case "audio" | "voice":
download_code = cast(str, raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
else:
voice_ext = cast(str, raw_content.get("fileExtension") or "")
if not voice_ext:
voice_ext = "amr"
voice_ext = voice_ext.lstrip(".")
f_path = await self.download_ding_file(
download_code,
robot_code,
voice_ext,
)
if f_path:
abm.message.append(Record.fromFileSystem(f_path))
case "file":
download_code = cast(str, raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
else:
file_name = cast(str, raw_content.get("fileName") or "")
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
if not file_ext:
file_ext = cast(str, raw_content.get("fileExtension") or "")
if not file_ext:
file_ext = "file"
f_path = await self.download_ding_file(
download_code,
robot_code,
file_ext,
)
if f_path:
if not file_name:
file_name = Path(f_path).name
abm.message.append(File(name=file_name, file=f_path))
abm.message.append(Image.fromFileSystem(f_path))
case "audio":
pass
await self._remember_sender_binding(message, abm)
return abm # 别忘了返回转换后的消息对象
@@ -351,23 +270,13 @@ class DingtalkPlatformAdapter(Platform):
)
return ""
resp_data = await resp.json()
download_url = cast(
str,
(
resp_data.get("downloadUrl")
or resp_data.get("data", {}).get("downloadUrl")
or ""
),
)
if not download_url:
logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}")
return ""
download_url = resp_data["data"]["downloadUrl"]
await download_file(download_url, str(f_path))
return str(f_path)
async def get_access_token(self) -> str:
try:
access_token = await asyncio.get_running_loop().run_in_executor(
access_token = await asyncio.get_event_loop().run_in_executor(
None,
self.client_.get_access_token,
)
@@ -632,28 +541,6 @@ class DingtalkPlatformAdapter(Platform):
self._safe_remove_file(cover_path)
if converted_video:
self._safe_remove_file(video_path)
elif isinstance(segment, File):
try:
file_path = await segment.get_file()
if not file_path:
logger.warning("钉钉文件发送失败: 无法解析文件路径")
continue
media_id = await self.upload_media(file_path, "file")
if not media_id:
continue
file_name = segment.name or Path(file_path).name
file_type = Path(file_name).suffix.lstrip(".")
await send_message(
msg_key="sampleFile",
msg_param={
"mediaId": media_id,
"fileName": file_name,
"fileType": file_type,
},
)
except Exception as e:
logger.warning(f"钉钉文件发送失败: {e}")
continue
async def send_message_chain_to_group(
self,
@@ -760,7 +647,7 @@ class DingtalkPlatformAdapter(Platform):
return
logger.error(f"钉钉机器人启动失败: {e}")
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, start_client, loop)
async def terminate(self) -> None:
@@ -1,371 +0,0 @@
import asyncio
import json
import re
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, AtAll, Image, Plain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient
from .kook_config import KookConfig
from .kook_event import KookEvent
@register_platform_adapter(
"kook",
"KOOK 适配器",
)
class KookPlatformAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(platform_config, event_queue)
self.kook_config = KookConfig.from_dict(platform_config)
logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}")
self.settings = platform_settings
self.client = KookClient(self.kook_config, self._on_received)
self._reconnect_task = None
self.running = False
self._main_task = None
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
inner_message = AstrBotMessage()
inner_message.session_id = session.session_id
inner_message.type = session.message_type
message_event = KookEvent(
message_str=message_chain.get_plain_text(),
message_obj=inner_message,
platform_meta=self.meta(),
session_id=session.session_id,
client=self.client,
)
await message_event.send(message_chain)
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="kook", description="KOOK 适配器", id=self.kook_config.id
)
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
bot_nickname = self.kook_config.bot_nickname.strip()
if not bot_nickname:
return False
author = payload.get("extra", {}).get("author", {})
if not isinstance(author, dict):
return False
author_nickname = author.get("nickname") or author.get("username") or ""
if not isinstance(author_nickname, str):
author_nickname = str(author_nickname)
return author_nickname.strip().casefold() == bot_nickname.casefold()
async def _on_received(self, data: dict):
logger.debug(f"KOOK 收到数据: {data}")
if "d" in data and data["s"] == 0:
payload = data["d"]
event_type = payload.get("type")
# 支持type=9(文本)和type=10(卡片)
if event_type in (9, 10):
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def run(self):
"""主运行循环"""
self.running = True
logger.info("[KOOK] 启动KOOK适配器")
# 启动主循环
self._main_task = asyncio.create_task(self._main_loop())
try:
await self._main_task
except asyncio.CancelledError:
logger.info("[KOOK] 适配器被取消")
except Exception as e:
logger.error(f"[KOOK] 适配器运行异常: {e}")
finally:
self.running = False
await self._cleanup()
async def _main_loop(self):
"""主循环,处理连接和重连"""
consecutive_failures = 0
max_consecutive_failures = self.kook_config.max_consecutive_failures
max_retry_delay = self.kook_config.max_retry_delay
while self.running:
try:
logger.info("[KOOK] 尝试连接KOOK服务器...")
# 尝试连接
success = await self.client.connect()
if success:
logger.info("[KOOK] 连接成功,开始监听消息")
consecutive_failures = 0 # 重置失败计数
# 等待连接结束(可能是正常关闭或异常)
while self.client.running and self.running:
try:
# 等待 client 内部触发 _stop_event,或者超时 1 秒后重试
# 使用 wait_for 配合 timeout 是为了防止极端情况下 self.running 变化没被察觉
await asyncio.wait_for(
self.client.wait_until_closed(), timeout=1.0
)
except asyncio.TimeoutError:
# 正常超时,继续下一轮 while 检查
continue
if self.running:
logger.warning("[KOOK] 连接断开,准备重连")
else:
consecutive_failures += 1
logger.error(
f"[KOOK] 连接失败,连续失败次数: {consecutive_failures}"
)
if consecutive_failures >= max_consecutive_failures:
logger.error("[KOOK] 连续失败次数过多,停止重连")
break
# 等待一段时间后重试
wait_time = min(
2**consecutive_failures, max_retry_delay
) # 指数退避
logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...")
await asyncio.sleep(wait_time)
except Exception as e:
consecutive_failures += 1
logger.error(f"[KOOK] 主循环异常: {e}")
if consecutive_failures >= max_consecutive_failures:
logger.error("[KOOK] 连续异常次数过多,停止重连")
break
await asyncio.sleep(5)
async def _cleanup(self):
"""清理资源"""
logger.info("[KOOK] 开始清理资源")
if self.client:
try:
await self.client.close()
except Exception as e:
logger.error(f"[KOOK] 关闭客户端异常: {e}")
if self._main_task and not self._main_task.done():
self._main_task.cancel()
try:
await self._main_task
except asyncio.CancelledError:
pass
logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message(
self, data: dict, self_id: str
) -> tuple[list, str]:
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
content = data.get("content") or ""
raw_content = kmarkdown.get("raw_content") or content
if not isinstance(content, str):
content = str(content)
if not isinstance(raw_content, str):
raw_content = str(raw_content)
mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.get("mention_part", [])
if isinstance(mention_part, list):
for item in mention_part:
if not isinstance(item, dict):
continue
mention_id = item.get("id")
if mention_id is None:
continue
mention_name_map[str(mention_id)] = str(item.get("username", ""))
components = []
cursor = 0
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
if match.start() > cursor:
plain_text = content[cursor : match.start()]
if plain_text:
components.append(Plain(text=plain_text))
mention_target = match.group(1).strip()
if mention_target == "all":
components.append(AtAll())
elif mention_target:
components.append(
At(
qq=mention_target,
name=mention_name_map.get(mention_target, ""),
)
)
cursor = match.end()
if cursor < len(content):
tail_text = content[cursor:]
if tail_text:
components.append(Plain(text=tail_text))
message_str = raw_content
if components:
for comp in components:
if isinstance(comp, Plain):
if not comp.text.strip():
continue
break
if isinstance(comp, At):
if str(comp.qq) == str(self_id):
message_str = re.sub(
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
"",
message_str,
count=1,
).strip()
break
if not components:
if message_str:
components = [Plain(text=message_str)]
else:
components = []
return components, message_str
def _parse_card_message(self, data: dict) -> tuple[list, str]:
content = data.get("content", "[]")
if not isinstance(content, str):
content = str(content)
card_list = json.loads(content)
text_parts: list[str] = []
images: list[str] = []
for card in card_list:
if not isinstance(card, dict):
continue
for module in card.get("modules", []):
if not isinstance(module, dict):
continue
module_type = module.get("type")
if module_type == "section":
section_text = module.get("text", {}).get("content", "")
if section_text:
text_parts.append(str(section_text))
continue
if module_type != "container":
continue
for element in module.get("elements", []):
if not isinstance(element, dict):
continue
if element.get("type") != "image":
continue
image_src = element.get("src")
if not isinstance(image_src, str):
logger.warning(
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
)
continue
if not image_src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
images.append(image_src)
text = "".join(text_parts)
message = []
if text:
message.append(Plain(text=text))
for img_url in images:
message.append(Image(file=img_url))
return message, text
async def convert_message(self, data: dict) -> AstrBotMessage:
abm = AstrBotMessage()
abm.raw_message = data
abm.self_id = self.client.bot_id
channel_type = data.get("channel_type")
author_id = data.get("author_id", "unknown")
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type:
case "GROUP":
session_id = data.get("target_id") or "unknown"
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
case "PERSON":
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
abm.session_id = data.get("author_id", "unknown")
case "BROADCAST":
session_id = data.get("target_id") or "unknown"
abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
case _:
raise ValueError(f"不支持的频道类型: {channel_type}")
abm.sender = MessageMember(
user_id=author_id,
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
)
abm.message_id = data.get("msg_id", "unknown")
# 普通文本消息
if data.get("type") == 9:
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
abm.message = message
abm.message_str = message_str
# 卡片消息
elif data.get("type") == 10:
try:
abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")]
else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")]
return abm
async def handle_msg(self, message: AstrBotMessage):
message_event = KookEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
)
self.commit_event(message_event)
@@ -1,437 +0,0 @@
import asyncio
import base64
import json
import os
import random
import time
import zlib
from pathlib import Path
import aiofiles
import aiohttp
import websockets
from astrbot import logger
from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig
from .kook_types import KookApiPaths, KookMessageType
class KookClient:
def __init__(self, config: KookConfig, event_callback):
# 数据字段
self.config = config
self._bot_id = ""
self._bot_name = ""
# 资源字段
self._http_client = aiohttp.ClientSession(
headers={
"Authorization": f"Bot {self.config.token}",
}
)
self.event_callback = event_callback # 回调函数,用于处理接收到的事件
self.ws = None
self.heartbeat_task = None
self._stop_event = asyncio.Event() # 用于通知连接结束
# 状态/计算字段
self.running = False
self.session_id = None
self.last_sn = 0 # 记录最后处理的消息序号
self.last_heartbeat_time = 0
self.heartbeat_failed_count = 0
@property
def bot_id(self):
return self._bot_id
@property
def bot_name(self):
return self._bot_name
async def get_bot_info(self) -> str:
"""获取机器人账号ID"""
url = KookApiPaths.USER_ME
try:
async with self._http_client.get(url) as resp:
if resp.status != 200:
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
return ""
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
return ""
bot_id: str = data["data"]["id"]
self._bot_id = bot_id
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
self._bot_name = bot_name
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
return bot_id
except Exception as e:
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
"""获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX
# 构建连接参数
params = {}
if resume:
params["resume"] = 1
params["sn"] = sn
if session_id:
params["session_id"] = session_id
try:
async with self._http_client.get(url, params=params) as resp:
if resp.status != 200:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取gateway失败: {data}")
return None
gateway_url: str = data["data"]["url"]
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
return gateway_url
except Exception as e:
logger.error(f"[KOOK] 获取gateway异常: {e}")
return None
async def connect(self, resume=False):
"""连接WebSocket"""
if self.ws:
try:
await self.ws.close()
except Exception:
pass
self.ws = None
self._stop_event.clear()
try:
# 获取gateway地址
gateway_url = await self.get_gateway_url(
resume=resume, sn=self.last_sn, session_id=self.session_id
)
await self.get_bot_info()
if not gateway_url:
return False
# 连接WebSocket
self.ws = await websockets.connect(gateway_url)
self.running = True
logger.info("[KOOK] WebSocket 连接成功")
# 启动心跳任务
if self.heartbeat_task:
self.heartbeat_task.cancel()
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
# 开始监听消息
await self.listen()
return True
except Exception as e:
logger.error(f"[KOOK] WebSocket 连接失败: {e}")
if self.ws:
try:
await self.ws.close()
except Exception:
pass
self.ws = None
return False
async def listen(self):
"""监听WebSocket消息"""
try:
while self.running:
try:
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
if isinstance(msg, bytes):
try:
msg = zlib.decompress(msg)
except Exception as e:
logger.error(f"[KOOK] 解压消息失败: {e}")
continue
msg = msg.decode("utf-8")
data = json.loads(msg)
# 处理不同类型的信令
await self._handle_signal(data)
except asyncio.TimeoutError:
# 超时检查,继续循环
continue
except websockets.exceptions.ConnectionClosed:
logger.warning("[KOOK] WebSocket连接已关闭")
break
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
break
except Exception as e:
logger.error(f"[KOOK] WebSocket 监听异常: {e}")
finally:
self.running = False
self._stop_event.set()
async def _handle_signal(self, data):
"""处理不同类型的信令"""
signal_type = data.get("s")
if signal_type == 0: # 事件消息
# 更新消息序号
if "sn" in data:
self.last_sn = data["sn"]
await self.event_callback(data)
elif signal_type == 1: # HELLO握手
await self._handle_hello(data)
elif signal_type == 3: # PONG心跳响应
await self._handle_pong(data)
elif signal_type == 5: # RECONNECT重连指令
await self._handle_reconnect(data)
elif signal_type == 6: # RESUME ACK
await self._handle_resume_ack(data)
else:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
async def _handle_hello(self, data):
"""处理HELLO握手"""
hello_data = data.get("d", {})
code = hello_data.get("code", 0)
if code == 0:
self.session_id = hello_data.get("session_id")
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
# TODO 重置重连延迟
# self.reconnect_delay = 1
else:
logger.error(f"[KOOK] 握手失败,错误码: {code}")
if code == 40103: # token过期
logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False
async def _handle_pong(self, data):
"""处理PONG心跳响应"""
self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0
async def _handle_reconnect(self, data):
"""处理重连指令"""
logger.warning("[KOOK] 收到重连指令")
# 清空本地状态
self.last_sn = 0
self.session_id = None
self.running = False
async def _handle_resume_ack(self, data):
"""处理RESUME确认"""
resume_data = data.get("d", {})
self.session_id = resume_data.get("session_id")
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self):
"""心跳循环"""
while self.running:
try:
# 随机化心跳间隔 (±5秒)
interval = max(
1, self.config.heartbeat_interval + random.randint(-5, 5)
)
await asyncio.sleep(interval)
if not self.running:
break
# 发送心跳
await self._send_ping()
# 等待PONG响应
await asyncio.sleep(self.config.heartbeat_timeout)
# 检查是否收到PONG响应
if (
time.time() - self.last_heartbeat_time
> self.config.heartbeat_timeout
):
self.heartbeat_failed_count += 1
logger.warning(
f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}"
)
if (
self.heartbeat_failed_count
>= self.config.max_heartbeat_failures
):
logger.error("[KOOK] 心跳失败次数过多,准备重连")
self.running = False
break
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"[KOOK] 心跳异常: {e}")
self.heartbeat_failed_count += 1
async def _send_ping(self):
"""发送心跳PING"""
try:
ping_data = {"s": 2, "sn": self.last_sn}
await self.ws.send(json.dumps(ping_data)) # type: ignore
except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}")
async def send_text(
self,
target_id: str,
content: str,
astrbot_message_type: MessageType,
kook_message_type: KookMessageType,
reply_message_id: str | int = "",
):
"""发送文本消息
消息发送接口文档参见: https://developer.kookapp.cn/doc/http/message#%E5%8F%91%E9%80%81%E9%A2%91%E9%81%93%E8%81%8A%E5%A4%A9%E6%B6%88%E6%81%AF
KMarkdown格式参见: https://developer.kookapp.cn/doc/kmarkdown-desc
"""
url = KookApiPaths.CHANNEL_MESSAGE_CREATE
if astrbot_message_type == MessageType.FRIEND_MESSAGE:
url = KookApiPaths.DIRECT_MESSAGE_CREATE
payload = {
"target_id": target_id,
"content": content,
"type": kook_message_type,
}
if reply_message_id:
payload["quote"] = reply_message_id
payload["reply_msg_id"] = reply_message_id
try:
async with self._http_client.post(url, json=payload) as resp:
if resp.status == 200:
result = await resp.json()
if result.get("code") != 0:
raise RuntimeError(
f'发送kook消息类型 "{kook_message_type.name}" 失败: {result}'
)
# else:
# logger.info("[KOOK] 发送消息成功")
else:
raise RuntimeError(
f'发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}'
)
except RuntimeError:
raise
except Exception as e:
logger.error(
f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}'
)
async def upload_asset(self, file_url: str | None) -> str:
"""上传文件到kook,获得远端资源url
接口定义参见: https://developer.kookapp.cn/doc/http/asset
"""
if not file_url:
return ""
bytes_data: bytes | None = None
filename = "unknown"
if file_url.startswith(("http://", "https://")):
filename = file_url.split("/")[-1]
return file_url
if file_url.startswith("base64:///"):
# b64decode的时候得开头留一个'/'的, 不然会报错
b64_str = file_url.removeprefix("base64://")
bytes_data = base64.b64decode(b64_str)
elif file_url.startswith("file://") or os.path.exists(file_url):
file_url = file_url.removeprefix("file:///")
file_url = file_url.removeprefix("file://")
try:
target_path = Path(file_url).resolve()
except Exception as exp:
logger.error(f'[KOOK] 获取文件 "{file_url}" 绝对路径失败: "{exp}"')
raise FileNotFoundError(
f'获取文件 "{file_url}" 绝对路径失败: "{exp}"'
) from exp
if not target_path.is_file():
raise FileNotFoundError(f"文件不存在: {target_path.name}")
filename = target_path.name
async with aiofiles.open(target_path, "rb") as f:
bytes_data = await f.read()
else:
raise ValueError(f'[KOOK] 不支持的文件资源类型: "{file_url}"')
data = aiohttp.FormData()
data.add_field("file", bytes_data, filename=filename)
url = KookApiPaths.ASSET_CREATE
try:
async with self._http_client.post(url, data=data) as resp:
if resp.status == 200:
result: dict = await resp.json()
logger.debug(f"[KOOK] 上传文件响应: {result}")
if result.get("code") == 0:
logger.info("[KOOK] 上传文件到kook服务器成功")
remote_url = result["data"]["url"]
logger.debug(f"[KOOK] 文件远端URL: {remote_url}")
return remote_url
else:
raise RuntimeError(f"上传文件到kook服务器失败: {result}")
else:
raise RuntimeError(
f"上传文件到kook服务器 HTTP错误: {resp.status} , {await resp.text()}"
)
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e
async def wait_until_closed(self):
"""提供给外部调用的等待方法"""
await self._stop_event.wait()
async def close(self):
"""关闭连接"""
self.running = False
self._stop_event.set()
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
if self.ws:
try:
await self.ws.close()
except Exception as e:
logger.error(f"[KOOK] 关闭WebSocket异常: {e}")
if self._http_client:
await self._http_client.close()
logger.info("[KOOK] 连接已关闭")
@@ -1,133 +0,0 @@
import json
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class KookConfig:
"""KOOK 适配器配置类"""
# 基础配置
token: str
bot_nickname: str = ""
enable: bool = False
id: str = "kook"
# 重连配置
reconnect_delay: int = 1
"""重连延迟基数(秒),指数退避"""
max_reconnect_delay: int = 60
"""最大重连延迟(秒)"""
max_retry_delay: int = 60
"""最大重试延迟(秒)"""
# 心跳配置
heartbeat_interval: int = 30
"""心跳间隔(秒)"""
heartbeat_timeout: int = 6
"""心跳超时时间(秒)"""
max_heartbeat_failures: int = 3
"""最大心跳失败次数"""
# 失败处理
max_consecutive_failures: int = 5
"""最大连续失败次数"""
@classmethod
def from_dict(cls, config_dict: dict) -> "KookConfig":
"""从字典创建配置对象"""
return cls(
# 适配器id 应该是不能改的
# id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get(
"kook_reconnect_delay",
KookConfig.reconnect_delay,
),
max_reconnect_delay=config_dict.get(
"kook_max_reconnect_delay",
KookConfig.max_reconnect_delay,
),
max_retry_delay=config_dict.get(
"kook_max_retry_delay",
KookConfig.max_retry_delay,
),
heartbeat_interval=config_dict.get(
"kook_heartbeat_interval",
KookConfig.heartbeat_interval,
),
heartbeat_timeout=config_dict.get(
"kook_heartbeat_timeout",
KookConfig.heartbeat_timeout,
),
max_heartbeat_failures=config_dict.get(
"kook_max_heartbeat_failures",
KookConfig.max_heartbeat_failures,
),
max_consecutive_failures=config_dict.get(
"kook_max_consecutive_failures",
KookConfig.max_consecutive_failures,
),
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def pretty_jsons(self, indent=2) -> str:
dict_config = self.to_dict()
dict_config["token"] = "*" * len(self.token) if self.token else "MISSING"
return json.dumps(dict_config, indent=indent, ensure_ascii=False)
# TODO 没用上的config配置,未来有空会实现这些配置描述的功能?
# # 连接配置
# CONNECTION_CONFIG = {
# # 心跳配置
# "heartbeat_interval": 30, # 心跳间隔(秒)
# "heartbeat_timeout": 6, # 心跳超时时间(秒)
# "max_heartbeat_failures": 3, # 最大心跳失败次数
# # 重连配置
# "initial_reconnect_delay": 1, # 初始重连延迟(秒)
# "max_reconnect_delay": 60, # 最大重连延迟(秒)
# "max_consecutive_failures": 5, # 最大连续失败次数
# # WebSocket配置
# "websocket_timeout": 10, # WebSocket接收超时(秒)
# "connection_timeout": 30, # 连接超时(秒)
# # 消息处理配置
# "enable_compression": True, # 是否启用消息压缩
# "max_message_size": 1024 * 1024, # 最大消息大小(字节)
# }
# # 日志配置
# LOGGING_CONFIG = {
# "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR
# "format": "[KOOK] %(message)s",
# "enable_heartbeat_logs": False, # 是否启用心跳日志
# "enable_message_logs": False, # 是否启用消息日志
# }
# # 错误处理配置
# ERROR_HANDLING_CONFIG = {
# "retry_on_network_error": True, # 网络错误时是否重试
# "retry_on_token_expired": True, # Token过期时是否重试
# "max_retry_attempts": 3, # 最大重试次数
# "retry_delay_base": 2, # 重试延迟基数(秒)
# }
# # 性能配置
# PERFORMANCE_CONFIG = {
# "enable_message_buffering": True, # 是否启用消息缓冲
# "buffer_size": 100, # 缓冲区大小
# "enable_connection_pooling": True, # 是否启用连接池
# "max_concurrent_requests": 10, # 最大并发请求数
# }
# # 安全配置
# SECURITY_CONFIG = {
# "verify_ssl": True, # 是否验证SSL证书
# "enable_rate_limiting": True, # 是否启用速率限制
# "rate_limit_requests": 100, # 速率限制请求数
# "rate_limit_window": 60, # 速率限制窗口(秒)
# }
@@ -1,209 +0,0 @@
import asyncio
import json
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.message.components import (
At,
AtAll,
BaseMessageComponent,
File,
Image,
Json,
Plain,
Record,
Reply,
Video,
)
from astrbot.core.platform import MessageType
from .kook_client import KookClient
from .kook_types import (
FileModule,
KookCardMessage,
KookCardMessageContainer,
KookMessageType,
OrderMessage,
)
class KookEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
client: KookClient,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
self.channel_id = message_obj.group_id or message_obj.session_id
self.astrbot_message_type: MessageType = message_obj.type
self._file_message_counter = 0
def _wrap_message(
self, index: int, message_component: BaseMessageComponent
) -> Coroutine[Any, Any, OrderMessage]:
async def wrap_upload(
index: int, message_type: KookMessageType, upload_coro
) -> OrderMessage:
url = await upload_coro
return OrderMessage(index=index, text=url, type=message_type)
async def handle_plain(
index: int,
text: str | None,
reply_id: str | int = "",
type: KookMessageType = KookMessageType.KMARKDOWN,
):
if not text:
text = ""
return OrderMessage(
index=index,
text=text,
type=type,
reply_id=reply_id,
)
match message_component:
case Image():
self._file_message_counter += 1
return wrap_upload(
index,
KookMessageType.IMAGE,
self.client.upload_asset(message_component.file),
)
case Video():
self._file_message_counter += 1
return wrap_upload(
index,
KookMessageType.VIDEO,
self.client.upload_asset(message_component.file),
)
case File():
async def handle_file(index: int, f_item: File):
f_data = await f_item.get_file()
url = await self.client.upload_asset(f_data)
return OrderMessage(
index=index, text=url, type=KookMessageType.FILE
)
self._file_message_counter += 1
return handle_file(index, message_component)
case Record():
async def handle_audio(index: int, f_item: Record):
file_path = await f_item.convert_to_file_path()
url = await self.client.upload_asset(file_path)
title = f_item.text or Path(file_path).name
return OrderMessage(
index=index,
text=KookCardMessageContainer(
[
KookCardMessage(
modules=[
FileModule(
type="audio",
title=title,
src=url,
)
]
)
]
).to_json(),
type=KookMessageType.CARD,
)
return handle_audio(index, message_component)
case Plain():
return handle_plain(index, message_component.text)
case At():
return handle_plain(index, f"(met){message_component.qq}(met)")
case AtAll():
return handle_plain(index, "(met)all(met)")
case Reply():
return handle_plain(index, "", reply_id=message_component.id)
case Json():
json_data = message_component.data
# kook卡片json外层得是一个列表
if isinstance(json_data, dict):
json_data = [json_data]
return handle_plain(
index,
# 考虑到kook可能会更改消息结构,为了能让插件开发者
# 自行根据kook文档描述填卡片json内容,故不做模型校验
# KookCardMessage().model_validate(message_component.data).to_json(),
text=json.dumps(json_data),
type=KookMessageType.CARD,
)
case _:
raise NotImplementedError(
f'kook适配器尚未实现对 "{message_component.type}" 消息类型的支持'
)
async def send(self, message: MessageChain):
file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = []
for index, item in enumerate(message.chain):
file_upload_tasks.append(self._wrap_message(index, item))
if self._file_message_counter > 0:
logger.debug("[Kook] 正在向kook服务器上传文件")
tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True)
order_messages: list[OrderMessage] = []
for index, result in enumerate(tasks_result):
if isinstance(result, BaseException):
logger.error(f"[Kook] {result}")
# 构造一个虚假的 OrderMessage,让用户知道这里本来有张图但坏了
# 这样后面的 for 循环就能把它当成普通文本发出去
err_node = OrderMessage(
index=index,
text=str(result),
type=KookMessageType.TEXT,
)
order_messages.append(err_node)
else:
order_messages.append(result)
order_messages.sort(key=lambda x: x.index)
reply_id: str | int = ""
errors: list[Exception] = []
for item in order_messages:
if item.reply_id:
reply_id = item.reply_id
if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
continue
try:
await self.client.send_text(
self.channel_id,
item.text,
self.astrbot_message_type,
item.type,
reply_id,
)
except RuntimeError as exp:
await self.client.send_text(
self.channel_id,
str(exp),
self.astrbot_message_type,
KookMessageType.TEXT,
reply_id,
)
errors.append(exp)
if errors:
err_msg = "\n".join([str(err) for err in errors])
logger.error(f"[kook] {err_msg}")
await super().send(message)
@@ -1,241 +0,0 @@
import json
from dataclasses import field
from enum import IntEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict
from pydantic.dataclasses import dataclass
class KookApiPaths:
"""Kook Api 路径"""
BASE_URL = "https://www.kookapp.cn"
API_VERSION_PATH = "/api/v3"
# 初始化相关
USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me"
GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index"
# 消息相关
ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create"
## 频道消息
CHANNEL_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/message/create"
## 私聊消息
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum):
TEXT = 1
IMAGE = 2
VIDEO = 3
FILE = 4
AUDIO = 8
KMARKDOWN = 9
CARD = 10
SYSTEM = 255
ThemeType = Literal[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
]
"""主题,可选的值为:primary, success, danger, warning, info, secondary, none.默认为 primary,为 none 时不显示侧边框。"""
SizeType = Literal["xs", "sm", "md", "lg"]
"""大小,可选值为:xs, sm, md, lg, 一般默认为 lg"""
SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"]
class KookCardColor(str):
"""16 进制色值"""
class KookCardModelBase:
"""卡片模块基类"""
type: str
@dataclass
class PlainTextElement(KookCardModelBase):
content: str
type: str = "plain-text"
emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase):
content: str
type: str = "kmarkdown"
@dataclass
class ImageElement(KookCardModelBase):
src: str
type: str = "image"
alt: str = ""
size: SizeType = "lg"
circle: bool = False
fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase):
text: str
type: str = "button"
theme: ThemeType = "primary"
value: str = ""
"""当为 link 时,会跳转到 value 代表的链接;
当为 return-val 系统会通过系统消息将消息 id,点击用户 id value 发回给发送者发送者可以根据自己的需求进行处理,消息事件参见button 点击事件私聊和频道内均可使用按钮点击事件"""
click: Literal["", "link", "return-val"] = ""
"""click 代表用户点击的事件,默认为"",代表无任何事件。"""
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement]
type: str = "paragraph"
cols: int = 1
"""范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase):
text: PlainTextElement
type: str = "header"
@dataclass
class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: str = "section"
mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合"""
elements: list[ImageElement]
type: str = "image-group"
@dataclass
class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement]
type: str = "container"
@dataclass
class ActionGroupModule(KookCardModelBase):
elements: list[ButtonElement]
type: str = "action-group"
@dataclass
class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素"""
type: str = "context"
@dataclass
class DividerModule(KookCardModelBase):
type: str = "divider"
@dataclass
class FileModule(KookCardModelBase):
src: str
title: str = ""
type: Literal["file", "audio", "video"] = "file"
cover: str | None = None
"""cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int
"""毫秒时间戳"""
type: str = "countdown"
startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day"
"""mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase):
code: str
"""邀请链接或者邀请码"""
type: str = "invite"
# 所有模块的联合类型
AnyModule = (
HeaderModule
| SectionModule
| ImageGroupModule
| ContainerModule
| ActionGroupModule
| ContextModule
| DividerModule
| FileModule
| CountdownModule
| InviteModule
)
class KookCardMessage(BaseModel):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息请使用KookCardMessageContainer
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
type: str = "card"
theme: ThemeType | None = None
size: SizeType | None = None
color: KookCardColor | None = None
modules: list[AnyModule] = field(default_factory=list)
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
def add_module(self, module: AnyModule):
self.modules.append(module)
def to_dict(self, exclude_none: bool = True):
"""exclude_none:去掉值为 None 字段,保留结构"""
return self.model_dump(exclude_none=exclude_none)
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
class KookCardMessageContainer(list[KookCardMessage]):
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
def append(self, object: KookCardMessage) -> None:
return super().append(object)
def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str:
return json.dumps(
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
)
@dataclass
class OrderMessage:
index: int
text: str
type: KookMessageType
reply_id: str | int = ""
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
@register_platform_adapter(
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
)
class LarkPlatformAdapter(Platform):
def __init__(
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
name="lark",
description="飞书机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_streaming_message=True,
support_streaming_message=False,
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
@@ -1,4 +1,3 @@
import asyncio
import base64
import json
import os
@@ -6,14 +5,6 @@ import uuid
from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.cardkit.v1 import (
ContentCardElementRequest,
ContentCardElementRequestBody,
CreateCardRequest,
CreateCardRequestBody,
SettingsCardRequest,
SettingsCardRequestBody,
)
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
@@ -37,7 +28,6 @@ from astrbot.core.utils.media_utils import (
convert_video_format,
get_media_duration,
)
from astrbot.core.utils.metrics import Metric
class LarkMessageEvent(AstrMessageEvent):
@@ -565,257 +555,15 @@ class LarkMessageEvent(AstrMessageEvent):
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
return
async def _create_streaming_card(self) -> str | None:
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return None
card_json = {
"schema": "2.0",
"header": {
"title": {"content": "", "tag": "plain_text"},
},
"config": {
"streaming_mode": True,
"summary": {"content": ""},
"streaming_config": {
"print_frequency_ms": {"default": 50},
"print_step": {"default": 2},
"print_strategy": "fast",
},
},
"body": {
"elements": [
{
"tag": "markdown",
"content": "",
"element_id": "markdown_1",
}
]
},
}
request = (
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.acreate(request)
except Exception as e:
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
return None
if not response.success():
logger.error(
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
)
return None
if response.data is None or not response.data.card_id:
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
return None
card_id = response.data.card_id
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
return card_id
async def _send_card_message(
self,
card_id: str,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
"""将卡片实体作为 interactive 消息发送。"""
content = json.dumps(
{"type": "card", "data": {"card_id": card_id}},
ensure_ascii=False,
)
return await self._send_im_message(
self.bot,
content=content,
msg_type="interactive",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
async def _update_streaming_text(
self,
card_id: str,
content: str,
sequence: int,
) -> bool:
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return False
request = (
ContentCardElementRequest.builder()
.card_id(card_id)
.element_id("markdown_1")
.request_body(
ContentCardElementRequestBody.builder()
.content(content)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card_element.acontent(request)
except Exception as e:
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
return False
if not response.success():
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
return False
return True
async def _close_streaming_mode(
self,
card_id: str,
sequence: int,
) -> None:
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return
settings_json = json.dumps(
{"config": {"streaming_mode": False}},
ensure_ascii=False,
)
request = (
SettingsCardRequest.builder()
.card_id(card_id)
.request_body(
SettingsCardRequestBody.builder()
.settings(settings_json)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.asettings(request)
except Exception as e:
logger.error(f"[Lark] 关闭流式模式失败: {e}")
return
if not response.success():
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
else:
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
async def send_streaming(self, generator, use_fallback: bool = False):
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if buffer:
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
async def send_streaming(self, generator, use_fallback: bool = False):
"""使用 CardKit 流式卡片实现打字机效果。
流程创建卡片实体 发送消息 流式更新文本 关闭流式模式
使用解耦发送循环LLM token 到达时只更新 buffer 并唤醒发送协程
发送频率由网络 RTT 自然限流
"""
# Step 1: 创建流式卡片实体
card_id = await self._create_streaming_card()
if not card_id:
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
# Step 2: 发送卡片消息
sent = await self._send_card_message(
card_id,
reply_message_id=self.message_obj.message_id,
)
if not sent:
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
sequence = 0
delta = ""
last_sent = ""
done = False
text_changed = asyncio.Event()
async def _sender_loop() -> None:
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
nonlocal sequence, last_sent
while not done:
await text_changed.wait()
text_changed.clear()
snapshot = delta
if snapshot and snapshot != last_sent:
sequence += 1
ok = await self._update_streaming_text(card_id, snapshot, sequence)
if ok:
last_sent = snapshot
if delta != snapshot:
text_changed.set()
sender_task = asyncio.create_task(_sender_loop())
try:
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
if chain.type == "break":
# 飞书卡片不支持分段,忽略 break
continue
for comp in chain.chain:
if isinstance(comp, Plain):
delta += comp.text
text_changed.set()
finally:
done = True
text_changed.set()
await sender_task
# Step 4: 必要时补发最终文本 + 关闭流式模式
if delta and delta != last_sent:
sequence += 1
await self._update_streaming_text(card_id, delta, sequence)
sequence += 1
await self._close_streaming_mode(card_id, sequence)
# Step 5: 内联父类 send_streaming 的副作用
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
@@ -104,7 +104,7 @@ class LineMessageEvent(AstrMessageEvent):
@staticmethod
async def _resolve_image_url(segment: Image) -> str:
candidate = (segment.url or segment.file or "").strip()
if candidate.startswith("https://"):
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
@@ -115,7 +115,7 @@ class LineMessageEvent(AstrMessageEvent):
@staticmethod
async def _resolve_record_url(segment: Record) -> str:
candidate = (segment.url or segment.file or "").strip()
if candidate.startswith("https://"):
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
@@ -137,7 +137,7 @@ class LineMessageEvent(AstrMessageEvent):
@staticmethod
async def _resolve_video_url(segment: Video) -> str:
candidate = (segment.file or "").strip()
if candidate.startswith("https://"):
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
try:
return await segment.register_to_file_service()
@@ -148,7 +148,9 @@ class LineMessageEvent(AstrMessageEvent):
@staticmethod
async def _resolve_video_preview_url(segment: Video) -> str:
cover_candidate = (segment.cover or "").strip()
if cover_candidate.startswith("https://"):
if cover_candidate.startswith("http://") or cover_candidate.startswith(
"https://"
):
return cover_candidate
if cover_candidate:
@@ -189,7 +191,7 @@ class LineMessageEvent(AstrMessageEvent):
@staticmethod
async def _resolve_file_url(segment: File) -> str:
if segment.url and segment.url.startswith("https://"):
if segment.url and segment.url.startswith(("http://", "https://")):
return segment.url
try:
return await segment.register_to_file_service()
@@ -80,7 +80,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if isinstance(source, botpy.message.C2CMessage):
# 真流式传输
current_time = asyncio.get_running_loop().time()
current_time = asyncio.get_event_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
@@ -90,7 +90,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
stream_payload["index"] += 1
stream_payload["id"] = ret["id"]
last_edit_time = asyncio.get_running_loop().time()
last_edit_time = asyncio.get_event_loop().time()
if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,并且传输 buffer 中剩余的消息
@@ -55,7 +55,7 @@ class QQOfficialWebhook:
max_async=1,
connect=bot_connect,
dispatch=self.client.ws_dispatch,
loop=asyncio.get_running_loop(),
loop=asyncio.get_event_loop(),
api=self.api,
)
+106 -310
View File
@@ -1,7 +1,6 @@
import asyncio
import os
import re
from collections.abc import Callable
from typing import Any, cast
import telegramify_markdown
@@ -22,7 +21,6 @@ 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):
@@ -36,20 +34,6 @@ 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,
@@ -355,118 +339,6 @@ 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
@@ -484,138 +356,6 @@ 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
@@ -626,69 +366,123 @@ class TelegramPlatformEvent(AstrMessageEvent):
# 发送初始 typing 状态
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = asyncio.get_running_loop().time()
def _append_text(t: str) -> None:
nonlocal delta
delta += t
last_chat_action_time = asyncio.get_event_loop().time()
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
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 chain.type == "break":
# 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
# 处理消息链中的每个组件
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),
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None
delta = ""
continue
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
await self._process_chain_items(
chain, payload, user_name, message_thread_id, _append_text
)
# 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
# 编辑或发送消息
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
current_time = asyncio.get_running_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
current_time = asyncio.get_running_loop().time()
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
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 状态(带节流)
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,
msg = await self.client.send_message(
text=delta, **cast(Any, payload)
)
current_content = delta
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
last_edit_time = asyncio.get_running_loop().time()
else:
current_time = asyncio.get_running_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_running_loop().time()
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:
@@ -712,3 +506,5 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
return await super().send_streaming(generator, use_fallback)
@@ -200,7 +200,7 @@ class WecomPlatformAdapter(Platform):
return msg_list[-1]
return None
msg_new = await asyncio.get_running_loop().run_in_executor(
msg_new = await asyncio.get_event_loop().run_in_executor(
None,
get_latest_msg_item,
)
@@ -261,7 +261,7 @@ class WecomPlatformAdapter(Platform):
@override
async def run(self) -> None:
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
if self.kf_name:
try:
acc_list = (
@@ -339,7 +339,7 @@ class WecomPlatformAdapter(Platform):
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, VoiceMessage):
resp: Response = await asyncio.get_running_loop().run_in_executor(
resp: Response = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
msg.media_id,
@@ -395,7 +395,7 @@ class WecomPlatformAdapter(Platform):
abm.message_str = text
elif msgtype == "image":
media_id = msg.get("image", {}).get("media_id", "")
resp: Response = await asyncio.get_running_loop().run_in_executor(
resp: Response = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
media_id,
@@ -407,7 +407,7 @@ class WecomPlatformAdapter(Platform):
abm.message = [Image(file=path, url=path)]
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
resp: Response = await asyncio.get_running_loop().run_in_executor(
resp: Response = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
media_id,
@@ -1,5 +1,5 @@
"""企业微信智能机器人平台适配器
基于企业微信智能机器人 API 的消息平台适配器支持 HTTP 回调与长连接
基于企业微信智能机器人 API 的消息平台适配器支持 HTTP 回调
参考webchat_adapter.py的队列机制实现异步消息处理和流式响应
"""
@@ -31,7 +31,6 @@ from .wecomai_api import (
WecomAIBotStreamMessageBuilder,
)
from .wecomai_event import WecomAIBotMessageEvent
from .wecomai_long_connection import WecomAIBotLongConnectionClient
from .wecomai_queue_mgr import WecomAIQueueMgr
from .wecomai_server import WecomAIBotServer
from .wecomai_utils import (
@@ -79,13 +78,8 @@ class WecomAIBotAdapter(Platform):
self.settings = platform_settings
# 初始化配置参数
self.connection_mode = self.config.get(
"wecom_ai_bot_connection_mode", "webhook"
)
self.token = self.config.get("token", self.config.get("wecomaibot_token", ""))
self.encoding_aes_key = self.config.get(
"encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "")
)
self.token = self.config["token"]
self.encoding_aes_key = self.config["encoding_aes_key"]
self.port = int(self.config["port"])
self.host = self.config.get("callback_server_host", "0.0.0.0")
self.bot_name = self.config.get("wecom_ai_bot_name", "")
@@ -102,52 +96,25 @@ class WecomAIBotAdapter(Platform):
self.only_use_webhook_url_to_send = bool(
self.config.get("only_use_webhook_url_to_send", False),
)
self.long_connection_bot_id = self.config.get(
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
)
self.long_connection_secret = self.config.get(
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
)
self.long_connection_ws_url = self.config.get(
"wecomaibot_ws_url",
"wss://openws.work.weixin.qq.com",
)
self.long_connection_heartbeat_interval = int(
self.config.get("wecomaibot_heartbeat_interval", 30),
)
# 平台元数据
self.metadata = PlatformMetadata(
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=bool(self.msg_push_webhook_url),
)
self.api_client: WecomAIBotAPIClient | None = None
self.server: WecomAIBotServer | None = None
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
# 初始化 API 客户端
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
if self.connection_mode == "long_connection":
if not self.long_connection_bot_id or not self.long_connection_secret:
logger.warning(
"企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败"
)
self.long_connection_client = WecomAIBotLongConnectionClient(
bot_id=self.long_connection_bot_id,
secret=self.long_connection_secret,
ws_url=self.long_connection_ws_url,
heartbeat_interval=self.long_connection_heartbeat_interval,
message_handler=self._process_long_connection_payload,
)
else:
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
self.server = WecomAIBotServer(
host=self.host,
port=self.port,
api_client=self.api_client,
message_handler=self._process_message,
)
# 初始化 HTTP 服务器
self.server = WecomAIBotServer(
host=self.host,
port=self.port,
api_client=self.api_client,
message_handler=self._process_message,
)
# 事件循环和关闭信号
self.shutdown_event = asyncio.Event()
@@ -194,9 +161,6 @@ class WecomAIBotAdapter(Platform):
加密后的响应消息无需响应时返回 None
"""
if not self.api_client:
logger.error("Webhook 消息处理失败: API 客户端未初始化")
return None
msgtype = message_data.get("msgtype")
if not msgtype:
logger.warning(f"消息类型未知,忽略: {message_data}")
@@ -356,89 +320,6 @@ class WecomAIBotAdapter(Platform):
logger.error("处理欢迎消息时发生异常: %s", e)
return None
async def _process_long_connection_payload(
self,
payload: dict[str, Any],
) -> None:
"""处理长连接回调消息。"""
cmd = payload.get("cmd")
headers = payload.get("headers") or {}
body = payload.get("body") or {}
req_id = headers.get("req_id")
if not isinstance(body, dict):
return
if cmd == "aibot_msg_callback":
session_id = self._extract_session_id(body)
stream_id = f"{session_id}_{generate_random_string(10)}"
await self._enqueue_message(
body, {"req_id": req_id or ""}, stream_id, session_id
)
self.queue_mgr.set_pending_response(
stream_id,
{
"req_id": req_id or "",
"connection_mode": "long_connection",
},
)
if self.initial_respond_text and req_id:
await self._send_long_connection_respond_msg(
req_id=req_id,
body={
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": False,
"content": self.initial_respond_text,
},
},
)
return
if cmd == "aibot_event_callback":
event = body.get("event") or {}
event_type = event.get("eventtype")
if (
event_type == "enter_chat"
and self.friend_message_welcome_text
and req_id
):
await self._send_long_connection_respond_welcome(req_id)
elif event_type == "disconnected_event":
logger.warning(
"[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭"
)
async def _send_long_connection_respond_welcome(self, req_id: str) -> bool:
client = self.long_connection_client
if not client:
return False
return await client.send_command(
cmd="aibot_respond_welcome_msg",
req_id=req_id,
body={
"msgtype": "text",
"text": {
"content": self.friend_message_welcome_text,
},
},
)
async def _send_long_connection_respond_msg(
self,
req_id: str,
body: dict[str, Any],
) -> bool:
client = self.long_connection_client
if not client:
return False
return await client.send_command(
cmd="aibot_respond_msg",
req_id=req_id,
body=body,
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
@@ -474,16 +355,15 @@ class WecomAIBotAdapter(Platform):
content = ""
image_base64 = []
_img_url_to_process: list[tuple[str, str | None]] = []
_img_url_to_process = []
msg_items = []
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
content = WecomAIBotMessageParser.parse_text_message(message_data)
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
image_payload = message_data.get("image", {})
image_url = image_payload.get("url", "")
if image_url:
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
_img_url_to_process.append(
WecomAIBotMessageParser.parse_image_message(message_data),
)
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
# 提取混合消息中的文本内容
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
@@ -494,12 +374,9 @@ class WecomAIBotAdapter(Platform):
if text_content:
text_parts.append(text_content)
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
image_payload = item.get("image", {})
image_url = image_payload.get("url", "")
image_url = item.get("image", {}).get("url", "")
if image_url:
_img_url_to_process.append(
(image_url, image_payload.get("aeskey"))
)
_img_url_to_process.append(image_url)
content = " ".join(text_parts) if text_parts else ""
else:
content = f"[{msgtype}消息]"
@@ -507,8 +384,8 @@ class WecomAIBotAdapter(Platform):
# 并行处理图片下载和解密
if _img_url_to_process:
tasks = [
process_encrypted_image(url, aes_key or self.encoding_aes_key)
for url, aes_key in _img_url_to_process
process_encrypted_image(url, self.encoding_aes_key)
for url in _img_url_to_process
]
results = await asyncio.gather(*tasks)
for success, result in results:
@@ -582,43 +459,26 @@ class WecomAIBotAdapter(Platform):
"""运行适配器,同时启动HTTP服务器和队列监听器"""
async def run_both() -> None:
if self.connection_mode == "long_connection":
if not self.long_connection_client:
raise RuntimeError("长连接客户端未初始化")
# 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
# 只运行队列监听器
await self.queue_listener.run()
else:
logger.info(
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
)
# 同时运行HTTP服务器和队列监听器
await asyncio.gather(
self.long_connection_client.start(),
self.server.start_server(),
self.queue_listener.run(),
)
else:
# 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
)
# 只运行队列监听器
await self.queue_listener.run()
else:
if not self.server:
raise RuntimeError("Webhook 服务器未初始化")
logger.info(
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
)
# 同时运行HTTP服务器和队列监听器
await asyncio.gather(
self.server.start_server(),
self.queue_listener.run(),
)
return run_both()
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
if self.connection_mode == "long_connection" or not self.server:
return "long_connection mode does not accept webhook callbacks", 400
# 根据请求方法分发到不同的处理函数
if request.method == "GET":
return await self.server.handle_verify(request)
@@ -629,10 +489,7 @@ class WecomAIBotAdapter(Platform):
"""终止适配器"""
logger.info("企业微信智能机器人适配器正在关闭...")
self.shutdown_event.set()
if self.long_connection_client:
await self.long_connection_client.shutdown()
if self.server:
await self.server.shutdown()
await self.server.shutdown()
def meta(self) -> PlatformMetadata:
"""获取平台元数据"""
@@ -650,22 +507,17 @@ class WecomAIBotAdapter(Platform):
queue_mgr=self.queue_mgr,
webhook_client=self.webhook_client,
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
long_connection_sender=self._send_long_connection_respond_msg,
)
message_event.is_at_or_wake_command = (
True # 企业微信智能机器人默认消息都是 at 或唤醒命令
)
message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
self.commit_event(message_event)
except Exception as e:
logger.error("处理消息时发生异常: %s", e)
def get_client(self) -> WecomAIBotAPIClient | None:
def get_client(self) -> WecomAIBotAPIClient:
"""获取 API 客户端"""
return self.api_client
def get_server(self) -> WecomAIBotServer | None:
def get_server(self) -> WecomAIBotServer:
"""获取 HTTP 服务器实例"""
return self.server
@@ -1,7 +1,5 @@
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
from collections.abc import Awaitable, Callable
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Image, Plain
@@ -20,11 +18,10 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
message_obj,
platform_meta,
session_id: str,
api_client: WecomAIBotAPIClient | None,
api_client: WecomAIBotAPIClient,
queue_mgr: WecomAIQueueMgr,
webhook_client: WecomAIBotWebhookClient | None = None,
only_use_webhook_url_to_send: bool = False,
long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
) -> None:
"""初始化消息事件
@@ -41,7 +38,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
self.queue_mgr = queue_mgr
self.webhook_client = webhook_client
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
self.long_connection_sender = long_connection_sender
async def _mark_stream_complete(self, stream_id: str) -> None:
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
@@ -121,18 +117,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
return data
@staticmethod
def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str:
if not message_chain:
return ""
plain_parts: list[str] = []
for comp in message_chain.chain:
if isinstance(comp, At):
plain_parts.append(f"@{comp.name} ")
elif isinstance(comp, Plain):
plain_parts.append(comp.text)
return "".join(plain_parts).strip()
async def send(self, message: MessageChain | None) -> None:
"""发送消息"""
raw = self.message_obj.raw_message
@@ -140,44 +124,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
"connection_mode"
)
req_id = pending_response.get("callback_params", {}).get("req_id")
if (
connection_mode == "long_connection"
and self.long_connection_sender
and isinstance(req_id, str)
and req_id
):
if self.only_use_webhook_url_to_send and self.webhook_client and message:
await self.webhook_client.send_message_chain(message)
await super().send(MessageChain([]))
return
if self.webhook_client and message:
await self.webhook_client.send_message_chain(
message,
unsupported_only=True,
)
content = self._extract_plain_text_from_chain(message)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": content,
},
},
)
await super().send(MessageChain([]))
return
if self.only_use_webhook_url_to_send and self.webhook_client and message:
await self.webhook_client.send_message_chain(message)
await self._mark_stream_complete(stream_id)
@@ -206,77 +152,8 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
"connection_mode"
)
req_id = pending_response.get("callback_params", {}).get("req_id")
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
if (
connection_mode == "long_connection"
and self.long_connection_sender
and isinstance(req_id, str)
and req_id
):
if self.only_use_webhook_url_to_send and self.webhook_client:
merged_chain = MessageChain([])
async for chain in generator:
merged_chain.chain.extend(chain.chain)
merged_chain.squash_plain()
await self.webhook_client.send_message_chain(merged_chain)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": "",
},
},
)
await super().send_streaming(generator, use_fallback)
return
increment_plain = ""
async for chain in generator:
if self.webhook_client:
await self.webhook_client.send_message_chain(
chain,
unsupported_only=True,
)
chain.squash_plain()
chunk_text = self._extract_plain_text_from_chain(chain)
if chunk_text:
increment_plain += chunk_text
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": False,
"content": increment_plain,
},
},
)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": increment_plain,
},
},
)
await super().send_streaming(generator, use_fallback)
return
if self.only_use_webhook_url_to_send and self.webhook_client:
merged_chain = MessageChain([])
async for chain in generator:
@@ -1,236 +0,0 @@
"""企业微信智能机器人长连接客户端。"""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
import aiohttp
from astrbot.api import logger
class WecomAIBotLongConnectionClient:
"""企业微信智能机器人 WebSocket 长连接客户端。"""
def __init__(
self,
bot_id: str,
secret: str,
ws_url: str,
heartbeat_interval: int,
message_handler: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
self.bot_id = bot_id
self.secret = secret
self.ws_url = ws_url
self.heartbeat_interval = max(5, int(heartbeat_interval))
self.message_handler = message_handler
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._shutdown_event = asyncio.Event()
self._send_lock = asyncio.Lock()
self._command_lock = asyncio.Lock()
self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
@staticmethod
def gen_req_id() -> str:
return uuid.uuid4().hex
async def start(self) -> None:
"""启动长连接并自动重连。"""
reconnect_delay = 1
while not self._shutdown_event.is_set():
try:
await self._run_once()
reconnect_delay = 1
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("[WecomAI][LongConn] 长连接异常: %s", e)
if self._shutdown_event.is_set():
break
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 30)
async def _run_once(self) -> None:
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None)
async with aiohttp.ClientSession(timeout=timeout) as session:
self._session = session
logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url)
async with session.ws_connect(
self.ws_url, heartbeat=None, autoping=True
) as ws:
self._ws = ws
await self._subscribe()
logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接")
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
while not self._shutdown_event.is_set():
message = await ws.receive()
if message.type == aiohttp.WSMsgType.TEXT:
await self._handle_text_message(message.data)
elif message.type in {
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR,
}:
break
finally:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
self._ws = None
async def _subscribe(self) -> None:
"""发送 aibot_subscribe,并等待响应。"""
req_id = self.gen_req_id()
payload = {
"cmd": "aibot_subscribe",
"headers": {"req_id": req_id},
"body": {"bot_id": self.bot_id, "secret": self.secret},
}
await self._send_json(payload)
if not self._ws:
raise RuntimeError("WebSocket 未建立")
reply = await self._ws.receive(timeout=10)
if reply.type != aiohttp.WSMsgType.TEXT:
raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}")
data = json.loads(reply.data)
if data.get("errcode") != 0:
raise RuntimeError(
f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}"
)
async def _heartbeat_loop(self) -> None:
while not self._shutdown_event.is_set():
await asyncio.sleep(self.heartbeat_interval)
if self._shutdown_event.is_set():
break
try:
await self.send_command("ping", self.gen_req_id(), None)
except Exception as e:
logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e)
return
async def _handle_text_message(self, text: str) -> None:
try:
payload = json.loads(text)
except json.JSONDecodeError:
logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text)
return
headers = payload.get("headers") or {}
req_id = headers.get("req_id")
if isinstance(req_id, str):
waiter = self._response_waiters.get(req_id)
if waiter and not waiter.done():
waiter.set_result(payload)
return
cmd = payload.get("cmd")
if cmd in {"aibot_msg_callback", "aibot_event_callback"}:
await self.message_handler(payload)
return
if payload.get("errcode") not in (None, 0):
logger.warning(
"[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s",
payload.get("errcode"),
payload.get("errmsg"),
)
async def send_command(
self,
cmd: str,
req_id: str,
body: dict[str, Any] | None,
) -> bool:
"""发送长连接命令。"""
headers = {"req_id": req_id}
payload: dict[str, Any] = {"cmd": cmd, "headers": headers}
if body is not None:
payload["body"] = body
async with self._command_lock:
max_retries = 3
for attempt in range(max_retries + 1):
response = await self._send_and_wait_response(req_id, payload)
if not response:
if attempt < max_retries:
await asyncio.sleep(min(0.2 * (2**attempt), 2.0))
continue
return False
errcode = response.get("errcode")
if errcode in (0, None):
return True
if errcode == 6000 and attempt < max_retries:
backoff = min(0.2 * (2**attempt), 2.0)
logger.warning(
"[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d",
cmd,
req_id,
attempt + 1,
)
await asyncio.sleep(backoff)
continue
logger.warning(
"[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s",
cmd,
req_id,
errcode,
response.get("errmsg"),
)
return False
return False
async def _send_and_wait_response(
self,
req_id: str,
payload: dict[str, Any],
timeout: float = 10.0,
) -> dict[str, Any] | None:
loop = asyncio.get_running_loop()
waiter: asyncio.Future[dict[str, Any]] = loop.create_future()
self._response_waiters[req_id] = waiter
try:
await self._send_json(payload)
return await asyncio.wait_for(waiter, timeout=timeout)
except TimeoutError:
logger.warning(
"[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s",
payload.get("cmd"),
req_id,
)
return None
finally:
self._response_waiters.pop(req_id, None)
async def _send_json(self, payload: dict[str, Any]) -> None:
ws = self._ws
if ws is None or ws.closed:
raise RuntimeError("长连接尚未建立")
async with self._send_lock:
await ws.send_json(payload)
async def shutdown(self) -> None:
self._shutdown_event.set()
ws = self._ws
if ws is not None and not ws.closed:
await ws.close()
session = self._session
if session is not None and not session.closed:
await session.close()
@@ -4,7 +4,6 @@
"""
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
@@ -83,7 +82,7 @@ class WecomAIQueueMgr:
del self.pending_responses[session_id]
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
if mark_finished:
self.completed_streams[session_id] = time.monotonic()
self.completed_streams[session_id] = asyncio.get_event_loop().time()
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
def remove_queue(self, session_id: str):
@@ -136,7 +135,7 @@ class WecomAIQueueMgr:
"""
self.pending_responses[session_id] = {
"callback_params": callback_params,
"timestamp": time.monotonic(),
"timestamp": asyncio.get_event_loop().time(),
}
logger.debug(f"[WecomAI] 设置待处理响应: {session_id}")
@@ -161,7 +160,7 @@ class WecomAIQueueMgr:
finished_at = self.completed_streams.get(session_id)
if finished_at is None:
return False
if time.monotonic() - finished_at > max_age_seconds:
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
self.completed_streams.pop(session_id, None)
return False
return True
@@ -173,7 +172,7 @@ class WecomAIQueueMgr:
max_age_seconds: 最大存活时间
"""
current_time = time.monotonic()
current_time = asyncio.get_event_loop().time()
expired_sessions = []
for session_id, response_data in self.pending_responses.items():
@@ -369,7 +369,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
if future:
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_running_loop().create_future()
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[msg_id] = future
await self.convert_message(msg, future)
# I love shield so much!
@@ -461,7 +461,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
elif msg.type == "voice":
assert isinstance(msg, VoiceMessage)
resp: Response = await asyncio.get_running_loop().run_in_executor(
resp: Response = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
msg.media_id,
+116 -454
View File
@@ -4,11 +4,7 @@ import asyncio
import copy
import json
import os
import threading
import urllib.parse
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any
import aiohttp
@@ -21,103 +17,6 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
MAX_MCP_TIMEOUT_SECONDS = 300.0
class MCPInitError(Exception):
"""Base exception for MCP initialization failures."""
class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError):
"""Raised when MCP client initialization exceeds the configured timeout."""
class MCPAllServicesFailedError(MCPInitError):
"""Raised when all configured MCP services fail to initialize."""
class MCPShutdownTimeoutError(asyncio.TimeoutError):
"""Raised when MCP shutdown exceeds the configured timeout."""
def __init__(self, names: list[str], timeout: float) -> None:
self.names = names
self.timeout = timeout
message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}"
super().__init__(message)
@dataclass
class MCPInitSummary:
total: int
success: int
failed: list[str]
@dataclass
class _MCPServerRuntime:
name: str
client: MCPClient
shutdown_event: asyncio.Event
lifecycle_task: asyncio.Task[None]
class _MCPClientDictView(Mapping[str, MCPClient]):
"""Read-only view of MCP clients derived from runtime state."""
def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None:
self._runtime = runtime
def __getitem__(self, key: str) -> MCPClient:
return self._runtime[key].client
def __iter__(self):
return iter(self._runtime)
def __len__(self) -> int:
return len(self._runtime)
def _resolve_timeout(
timeout: float | int | str | None = None,
*,
env_name: str = MCP_INIT_TIMEOUT_ENV,
default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
) -> float:
"""Resolve timeout with precedence: explicit argument > env value > default."""
source = f"环境变量 {env_name}"
if timeout is None:
timeout = os.getenv(env_name, str(default))
else:
source = "显式参数 timeout"
try:
timeout_value = float(timeout)
except (TypeError, ValueError):
logger.warning(
f"超时配置({source}={timeout!r} 无效,使用默认值 {default:g} 秒。"
)
return default
if timeout_value <= 0:
logger.warning(
f"超时配置({source}={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。"
)
return default
if timeout_value > MAX_MCP_TIMEOUT_SECONDS:
logger.warning(
f"超时配置({source}={timeout_value:g} 过大,已限制为最大值 "
f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。"
)
return MAX_MCP_TIMEOUT_SECONDS
return timeout_value
SUPPORTED_TYPES = [
"string",
"number",
@@ -207,49 +106,9 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
class FunctionToolManager:
def __init__(self) -> None:
self.func_list: list[FuncTool] = []
self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {}
"""MCP 服务运行时状态(唯一事实来源)"""
self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime)
self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime)
self._timeout_mismatch_warned = False
self._timeout_warn_lock = threading.Lock()
self._runtime_lock = asyncio.Lock()
self._mcp_starting: set[str] = set()
self._init_timeout_default = _resolve_timeout(
timeout=None,
env_name=MCP_INIT_TIMEOUT_ENV,
default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
)
self._enable_timeout_default = _resolve_timeout(
timeout=None,
env_name=ENABLE_MCP_TIMEOUT_ENV,
default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS,
)
self._warn_on_timeout_mismatch(
self._init_timeout_default,
self._enable_timeout_default,
)
@property
def mcp_client_dict(self) -> Mapping[str, MCPClient]:
"""Read-only compatibility view for external callers that still read mcp_client_dict.
Note: Mutating this mapping is unsupported and will raise TypeError.
"""
return self._mcp_client_dict_view
@property
def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]:
"""Read-only view of MCP runtime metadata for external callers."""
return self._mcp_server_runtime_view
@property
def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]:
"""Backward-compatible read-only view (deprecated). Do not mutate.
Note: Mutations are not supported and will raise TypeError.
"""
return self._mcp_server_runtime_view
self.mcp_client_dict: dict[str, MCPClient] = {}
"""MCP 服务列表"""
self.mcp_client_event: dict[str, asyncio.Event] = {}
def empty(self) -> bool:
return len(self.func_list) == 0
@@ -320,34 +179,7 @@ class FunctionToolManager:
tool_set = ToolSet(self.func_list.copy())
return tool_set
@staticmethod
def _log_safe_mcp_debug_config(cfg: dict) -> None:
# 仅记录脱敏后的摘要,避免泄露 command/args/url 中的敏感信息
if "command" in cfg:
cmd = cfg["command"]
executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd)
args_val = cfg.get("args", [])
args_count = (
len(args_val)
if isinstance(args_val, (list, tuple))
else (0 if args_val is None else 1)
)
logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}")
return
if "url" in cfg:
parsed = urllib.parse.urlparse(str(cfg["url"]))
host = parsed.hostname or ""
scheme = parsed.scheme or "unknown"
try:
port = f":{parsed.port}" if parsed.port else ""
except ValueError:
port = ""
logger.debug(f" 主机: {scheme}://{host}{port}")
async def init_mcp_clients(
self, raise_on_all_failed: bool = False
) -> MCPInitSummary:
async def init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
{
@@ -365,10 +197,6 @@ class FunctionToolManager:
...
}
```
Timeout behavior:
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT独立于初始化超时
"""
data_dir = get_astrbot_data_path()
@@ -378,217 +206,56 @@ class FunctionToolManager:
with open(mcp_json_file, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
return MCPInitSummary(total=0, success=0, failed=[])
return
with open(mcp_json_file, encoding="utf-8") as f:
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
mcp_server_json_obj: dict[str, dict] = json.load(
open(mcp_json_file, encoding="utf-8"),
)["mcpServers"]
init_timeout = self._init_timeout_default
timeout_display = f"{init_timeout:g}"
active_configs: list[tuple[str, dict, asyncio.Event]] = []
for name, cfg in mcp_server_json_obj.items():
for name in mcp_server_json_obj:
cfg = mcp_server_json_obj[name]
if cfg.get("active", True):
shutdown_event = asyncio.Event()
active_configs.append((name, cfg, shutdown_event))
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(name, cfg, event),
)
self.mcp_client_event[name] = event
if not active_configs:
return MCPInitSummary(total=0, success=0, failed=[])
logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...")
init_tasks = [
asyncio.create_task(
self._start_mcp_server(
name=name,
cfg=cfg,
shutdown_event=shutdown_event,
timeout=init_timeout,
),
name=f"mcp-init:{name}",
)
for (name, cfg, shutdown_event) in active_configs
]
results = await asyncio.gather(*init_tasks, return_exceptions=True)
success_count = 0
failed_services: list[str] = []
for (name, cfg, _), result in zip(active_configs, results, strict=False):
if isinstance(result, Exception):
if isinstance(result, MCPInitTimeoutError):
logger.error(
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
)
else:
logger.error(f"Failed to initialize MCP server {name}: {result}")
self._log_safe_mcp_debug_config(cfg)
failed_services.append(name)
async with self._runtime_lock:
self._mcp_server_runtime.pop(name, None)
continue
success_count += 1
if failed_services:
logger.warning(
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
f"Please check the mcp_server.json file and server availability."
)
summary = MCPInitSummary(
total=len(active_configs), success=success_count, failed=failed_services
)
logger.info(
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
)
if summary.total > 0 and summary.success == 0:
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
if raise_on_all_failed:
raise MCPAllServicesFailedError(msg)
logger.error(msg)
return summary
async def _start_mcp_server(
async def _init_mcp_client_task_wrapper(
self,
name: str,
cfg: dict,
*,
shutdown_event: asyncio.Event | None = None,
timeout: float,
event: asyncio.Event,
ready_future: asyncio.Future | None = None,
) -> None:
"""Initialize MCP server with timeout and register task/event together.
This method is idempotent. If the server is already running, the existing
runtime is kept and the new config is ignored.
"""
async with self._runtime_lock:
if name in self._mcp_server_runtime or name in self._mcp_starting:
logger.warning(
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
)
self._log_safe_mcp_debug_config(cfg)
return
self._mcp_starting.add(name)
if shutdown_event is None:
shutdown_event = asyncio.Event()
mcp_client: MCPClient | None = None
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
try:
mcp_client = await asyncio.wait_for(
self._init_mcp_client(name, cfg),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
raise MCPInitTimeoutError(
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from exc
except Exception:
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
raise
await self._init_mcp_client(name, cfg)
tools = await self.mcp_client_dict[name].list_tools_and_save()
if ready_future and not ready_future.done():
# tell the caller we are ready
ready_future.set_result(tools)
await event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
if ready_future and not ready_future.done():
ready_future.set_exception(e)
finally:
if mcp_client is None:
async with self._runtime_lock:
self._mcp_starting.discard(name)
# 无论如何都能清理
await self._terminate_mcp_client(name)
async def lifecycle() -> None:
try:
await shutdown_event.wait()
logger.info(f"Received shutdown signal for MCP client {name}")
except asyncio.CancelledError:
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
await self._terminate_mcp_client(name)
lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}")
async with self._runtime_lock:
self._mcp_server_runtime[name] = _MCPServerRuntime(
name=name,
client=mcp_client,
shutdown_event=shutdown_event,
lifecycle_task=lifecycle_task,
)
self._mcp_starting.discard(name)
async def _shutdown_runtimes(
self,
runtimes: list[_MCPServerRuntime],
timeout: float,
*,
strict: bool = True,
) -> list[str]:
"""Shutdown runtimes and wait for lifecycle tasks to complete."""
lifecycle_tasks = [
runtime.lifecycle_task
for runtime in runtimes
if not runtime.lifecycle_task.done()
]
if not lifecycle_tasks:
return []
for runtime in runtimes:
runtime.shutdown_event.set()
try:
results = await asyncio.wait_for(
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
timeout=timeout,
)
except asyncio.TimeoutError:
pending_names = [
runtime.name
for runtime in runtimes
if not runtime.lifecycle_task.done()
]
for task in lifecycle_tasks:
if not task.done():
task.cancel()
await asyncio.gather(*lifecycle_tasks, return_exceptions=True)
if strict:
raise MCPShutdownTimeoutError(pending_names, timeout)
logger.warning(
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
f"{timeout:g}",
", ".join(pending_names),
)
return pending_names
else:
for result in results:
if isinstance(result, asyncio.CancelledError):
logger.debug("MCP lifecycle task was cancelled during shutdown.")
elif isinstance(result, Exception):
logger.error(
"MCP lifecycle task failed during shutdown.",
exc_info=(type(result), result, result.__traceback__),
)
return []
async def _cleanup_mcp_client_safely(
self, mcp_client: MCPClient, name: str
) -> None:
"""安全清理单个 MCP 客户端,避免清理异常中断主流程。"""
try:
await mcp_client.cleanup()
except Exception as cleanup_exc: # noqa: BLE001 - only log here
logger.error(
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
)
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
async def _init_mcp_client(self, name: str, config: dict) -> None:
"""初始化单个MCP客户端"""
# 先清理之前的客户端,如果存在
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
mcp_client = MCPClient()
mcp_client.name = name
try:
await mcp_client.connect_to_server(config, name)
tools_res = await mcp_client.list_tools_and_save()
except asyncio.CancelledError:
await self._cleanup_mcp_client_safely(mcp_client, name)
raise
except Exception:
await self._cleanup_mcp_client_safely(mcp_client, name)
raise
self.mcp_client_dict[name] = mcp_client
await mcp_client.connect_to_server(config, name)
tools_res = await mcp_client.list_tools_and_save()
logger.debug(f"MCP server {name} list tools response: {tools_res}")
tool_names = [tool.name for tool in tools_res.tools]
@@ -608,37 +275,27 @@ class FunctionToolManager:
)
self.func_list.append(func_tool)
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
return mcp_client
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
async with self._runtime_lock:
runtime = self._mcp_server_runtime.get(name)
if runtime:
client = runtime.client
# 关闭MCP连接
await self._cleanup_mcp_client_safely(client, name)
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
async with self._runtime_lock:
self._mcp_server_runtime.pop(name, None)
self._mcp_starting.discard(name)
logger.info(f"Disconnected from MCP server {name}")
return
# Runtime missing but stale tools may still exist after failed flows.
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
async with self._runtime_lock:
self._mcp_starting.discard(name)
if name in self.mcp_client_dict:
client = self.mcp_client_dict[name]
try:
# 关闭MCP连接
await client.cleanup()
except Exception as e:
logger.error(f"清空 MCP 客户端资源 {name}: {e}")
finally:
# Remove client from dict after cleanup attempt (successful or not)
self.mcp_client_dict.pop(name, None)
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
@staticmethod
async def test_mcp_server_connection(config: dict) -> list[str]:
@@ -662,36 +319,42 @@ class FunctionToolManager:
self,
name: str,
config: dict,
shutdown_event: asyncio.Event | None = None,
timeout: float | int | str | None = None,
event: asyncio.Event | None = None,
ready_future: asyncio.Future | None = None,
timeout: int = 30,
) -> None:
"""Enable a new MCP server and initialize it.
"""Enable_mcp_server a new MCP server to the manager and initialize it.
Args:
name: The name of the MCP server.
config: Configuration for the MCP server.
shutdown_event: Event to signal when the MCP client should shut down.
timeout: Timeout in seconds for initialization.
Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout).
name (str): The name of the MCP server.
config (dict): Configuration for the MCP server.
event (asyncio.Event): Event to signal when the MCP client is ready.
ready_future (asyncio.Future): Future to signal when the MCP client is ready.
timeout (int): Timeout for the initialization.
Raises:
MCPInitTimeoutError: If initialization does not complete within timeout.
TimeoutError: If the initialization does not complete within the specified timeout.
Exception: If there is an error during initialization.
"""
if timeout is None:
timeout_value = self._enable_timeout_default
else:
timeout_value = _resolve_timeout(
timeout=timeout,
env_name=ENABLE_MCP_TIMEOUT_ENV,
default=self._enable_timeout_default,
)
await self._start_mcp_server(
name=name,
cfg=config,
shutdown_event=shutdown_event,
timeout=timeout_value,
if not event:
event = asyncio.Event()
if not ready_future:
ready_future = asyncio.Future()
if name in self.mcp_client_dict:
return
asyncio.create_task(
self._init_mcp_client_task_wrapper(name, config, event, ready_future),
)
try:
await asyncio.wait_for(ready_future, timeout=timeout)
finally:
self.mcp_client_event[name] = event
if ready_future.done() and ready_future.exception():
exc = ready_future.exception()
if exc is not None:
raise exc
async def disable_mcp_server(
self,
@@ -704,40 +367,39 @@ class FunctionToolManager:
name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled.
timeout (int): Timeout.
Raises:
MCPShutdownTimeoutError: If shutdown does not complete within timeout.
Only raised when disabling a specific server (name is not None).
"""
if name:
async with self._runtime_lock:
runtime = self._mcp_server_runtime.get(name)
if runtime is None:
if name not in self.mcp_client_event:
return
await self._shutdown_runtimes([runtime], timeout, strict=True)
client = self.mcp_client_dict.get(name)
self.mcp_client_event[name].set()
if not client:
return
client_running_event = client.running_event
try:
await asyncio.wait_for(client_running_event.wait(), timeout=timeout)
finally:
self.mcp_client_event.pop(name, None)
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
else:
async with self._runtime_lock:
runtimes = list(self._mcp_server_runtime.values())
await self._shutdown_runtimes(runtimes, timeout, strict=False)
def _warn_on_timeout_mismatch(
self,
init_timeout: float,
enable_timeout: float,
) -> None:
if init_timeout == enable_timeout:
return
with self._timeout_warn_lock:
if self._timeout_mismatch_warned:
return
logger.info(
"检测到 MCP 初始化超时与动态启用超时配置不同:"
"初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。",
f"{init_timeout:g}",
f"{enable_timeout:g}",
)
self._timeout_mismatch_warned = True
running_events = [
client.running_event.wait() for client in self.mcp_client_dict.values()
]
for key, event in self.mcp_client_event.items():
event.set()
# waiting for all clients to finish
try:
await asyncio.wait_for(asyncio.gather(*running_events), timeout=timeout)
finally:
self.mcp_client_event.clear()
self.mcp_client_dict.clear()
self.func_list = [
f for f in self.func_list if not isinstance(f, MCPTool)
]
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
"""获得 OpenAI API 风格的**已经激活**的工具描述"""
+2 -19
View File
@@ -79,7 +79,6 @@ class ProviderManager:
self._provider_change_hooks: list[
Callable[[str, ProviderType, str | None], None]
] = []
self._mcp_init_task: asyncio.Task | None = None
def set_provider_change_callback(
self,
@@ -331,17 +330,8 @@ class ProviderManager:
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
async def _init_mcp_clients_bg() -> None:
try:
await self.llm_tools.init_mcp_clients()
except Exception:
logger.error("MCP init background task failed", exc_info=True)
if self._mcp_init_task is None or self._mcp_init_task.done():
self._mcp_init_task = asyncio.create_task(
_init_mcp_clients_bg(),
name="provider-manager:mcp-init",
)
# 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str) -> None:
"""动态导入提供商适配器模块
@@ -810,13 +800,6 @@ class ProviderManager:
await self.load_provider(new_config)
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
self._mcp_init_task.cancel()
try:
await self._mcp_init_task
except asyncio.CancelledError:
pass
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate() # type: ignore
+1 -18
View File
@@ -281,24 +281,7 @@ class TTSProvider(AbstractProvider):
accumulated_text += text_part
async def test(self) -> None:
audio_path = await self.get_audio("hi")
# 检查生成的音频文件是否有效
if not os.path.exists(audio_path):
raise Exception("TTS test failed: audio file was not created")
file_size = os.path.getsize(audio_path)
if file_size == 0:
raise Exception(
"TTS test failed: generated audio file is empty (0 bytes). "
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
)
# 清理测试文件
try:
os.remove(audio_path)
except Exception:
pass
await self.get_audio("hi")
class EmbeddingProvider(AbstractProvider):
@@ -276,24 +276,9 @@ class ProviderAnthropic(Provider):
llm_response.id = completion.id
llm_response.usage = self._extract_usage(completion.usage)
# Handle cases where completion only contains ThinkingBlock (e.g., MiniMax max_tokens)
# When stop_reason='max_tokens', the model may return only thinking content
# This is valid and should not raise an exception
# TODO(Soulter): 处理 end_turn 情况
if not llm_response.completion_text and not llm_response.tools_call_args:
# Guard clause: raise early if no valid content at all
if not llm_response.reasoning_content:
raise ValueError(
f"Anthropic API returned unparsable completion: "
f"no text, tool_use, or thinking content found. "
f"Completion: {completion}"
)
# We have reasoning content (ThinkingBlock) - this is valid
stop_reason = getattr(completion, "stop_reason", "unknown")
logger.debug(
f"Completion contains only ThinkingBlock (stop_reason={stop_reason})"
)
llm_response.completion_text = "" # Ensure empty string, not None
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
return llm_response
@@ -20,7 +20,6 @@ from ..register import register_provider_adapter
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
class OTTSProvider:
@@ -117,7 +116,7 @@ class AzureNativeProvider(TTSProvider):
"azure_tts_subscription_key",
"",
).strip()
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = (
@@ -236,9 +235,9 @@ class AzureTTSProvider(TTSProvider):
raise ValueError(error_msg) from e
except KeyError as e:
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
return AzureNativeProvider(config, self.provider_settings)
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
async def get_audio(self, text: str) -> str:
if isinstance(self.provider, OTTSProvider):
@@ -87,7 +87,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
model: str,
text: str,
) -> tuple[bytes | None, str]:
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self._call_qwen_tts, model, text)
audio_bytes = await self._extract_audio_from_response(response)
if not audio_bytes:
@@ -143,7 +143,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
voice=self.voice,
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
)
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
audio_bytes = await loop.run_in_executor(
None,
synthesizer.call,
+2 -2
View File
@@ -59,7 +59,7 @@ class GenieTTSProvider(TTSProvider):
filename = f"genie_tts_{uuid.uuid4()}.wav"
path = os.path.join(temp_dir, filename)
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
def _generate(save_path: str) -> None:
assert genie is not None
@@ -85,7 +85,7 @@ class GenieTTSProvider(TTSProvider):
text_queue: asyncio.Queue[str | None],
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
) -> None:
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
while True:
text = await text_queue.get()
@@ -154,14 +154,6 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
audio_stream = self._call_tts_stream(text)
audio = await self._audio_play(audio_stream)
# 检查音频数据是否为空
if not audio or len(audio) == 0:
raise Exception(
"MiniMax TTS API returned empty audio data. "
"Please verify your configuration, especially the 'group_id' parameter. "
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
)
# 结果保存至文件
with open(path, "wb") as file:
file.write(audio)
@@ -169,4 +161,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
return path
except aiohttp.ClientError as e:
raise Exception(f"MiniMax TTS API request failed: {e!s}")
raise e
@@ -43,7 +43,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...")
# 将模型加载放到线程池中执行
self.model = await asyncio.get_running_loop().run_in_executor(
self.model = await asyncio.get_event_loop().run_in_executor(
None,
lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16),
)
@@ -88,7 +88,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
audio_url = output_path
# 使用 run_in_executor 来调用模型进行识别
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
res = await loop.run_in_executor(
None, # 使用默认的线程池
lambda: cast(SenseVoiceSmall, self.model)(
@@ -31,7 +31,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
self.model = None
async def initialize(self) -> None:
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...")
self.model = await loop.run_in_executor(
None,
@@ -50,7 +50,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
return False
async def get_text(self, audio_url: str) -> str:
loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
is_tencent = False
+2 -17
View File
@@ -26,13 +26,6 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _is_ignored_zip_entry(name: str) -> bool:
parts = PurePosixPath(name).parts
if not parts:
return True
return parts[0] == "__MACOSX"
@dataclass
class SkillInfo:
name: str
@@ -408,11 +401,7 @@ class SkillManager:
raise ValueError("Uploaded file is not a valid zip archive.")
with zipfile.ZipFile(zip_path) as zf:
names = [
name
for name in (entry.replace("\\", "/") for entry in zf.namelist())
if name and not _is_ignored_zip_entry(name)
]
names = [name.replace("\\", "/") for name in zf.namelist()]
file_names = [name for name in names if name and not name.endswith("/")]
if not file_names:
raise ValueError("Zip archive is empty.")
@@ -447,11 +436,7 @@ class SkillManager:
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
for member in zf.infolist():
member_name = member.filename.replace("\\", "/")
if not member_name or _is_ignored_zip_entry(member_name):
continue
zf.extract(member, tmp_dir)
zf.extractall(tmp_dir)
src_dir = Path(tmp_dir) / skill_name
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
+1 -1
View File
@@ -15,4 +15,4 @@ class RegexFilter(HandlerFilter):
self.regex = re.compile(regex)
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return bool(self.regex.search(event.get_message_str().strip()))
return bool(self.regex.match(event.get_message_str().strip()))
+10 -111
View File
@@ -14,12 +14,7 @@ import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import (
DependencyConflictError,
logger,
pip_installer,
sp,
)
from astrbot.core import logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
@@ -32,10 +27,6 @@ from astrbot.core.utils.astrbot_path import (
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
)
from . import StarMetadata
from .command_management import sync_command_configs
@@ -57,49 +48,6 @@ class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
def __init__(
self,
*,
plugin_label: str,
requirements_path: str,
error: Exception,
) -> None:
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
super().__init__(message)
self.plugin_label = plugin_label
self.requirements_path = requirements_path
self.error = error
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
try:
missing = find_missing_requirements_or_raise(requirements_path)
except RequirementsPrecheckFailed:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not missing:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(missing)}"
)
await pip_installer.install(requirements_path=requirements_path)
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
@@ -250,37 +198,15 @@ class PluginManager:
to_update.append(p.root_dir_name)
for p in to_update:
plugin_path = os.path.join(plugin_dir, p)
await self._ensure_plugin_requirements(plugin_path, p)
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
pth = os.path.join(plugin_path, "requirements.txt")
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
try:
await pip_installer.install(requirements_path=pth)
except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
return True
async def _ensure_plugin_requirements(
self,
plugin_dir_path: str,
plugin_label: str,
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not os.path.exists(requirements_path):
return
try:
await _install_requirements_with_precheck(
plugin_label=plugin_label,
requirements_path=requirements_path,
)
except asyncio.CancelledError:
raise
except DependencyConflictError as e:
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
raise
except Exception as e:
dependency_error = PluginDependencyInstallError(
plugin_label=plugin_label,
requirements_path=requirements_path,
error=e,
)
logger.exception(str(dependency_error))
raise dependency_error from e
async def _import_plugin_with_dependency_recovery(
self,
path: str,
@@ -496,7 +422,7 @@ class PluginManager:
root_dir_name: str,
plugin_dir_path: str,
reserved: bool,
error: BaseException | str,
error: Exception | str,
error_trace: str,
) -> dict:
record: dict = {
@@ -569,9 +495,6 @@ class PluginManager:
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
@@ -1155,10 +1078,6 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
@@ -1398,12 +1317,6 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
await self._ensure_plugin_requirements(
plugin_dir_path,
plugin_name,
)
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str) -> None:
@@ -1461,23 +1374,10 @@ class PluginManager:
return
if "__del__" in star_metadata.star_cls_type.__dict__:
loop = asyncio.get_running_loop()
future = loop.run_in_executor(
asyncio.get_event_loop().run_in_executor(
None,
star_metadata.star_cls.__del__,
)
def _log_del_exception(fut: asyncio.Future) -> None:
if fut.cancelled():
return
if (exc := fut.exception()) is not None:
logger.error(
"插件 %s 在 __del__ 中抛出了异常:%r",
star_metadata.name,
exc,
)
future.add_done_callback(_log_del_exception)
elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate()
@@ -1575,7 +1475,6 @@ class PluginManager:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
await self._ensure_plugin_requirements(desti_dir, dir_name)
# await self.reload()
success, error_message = await self.load(
specified_dir_name=dir_name,
+1 -3
View File
@@ -149,9 +149,7 @@ class AstrBotUpdator(RepoZipUpdator):
file_url = None
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception(
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
) # 避免版本管理混乱
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
-121
View File
@@ -1,121 +0,0 @@
import contextlib
import functools
import importlib.metadata as importlib_metadata
import logging
import os
from collections.abc import Iterator
from packaging.requirements import Requirement
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name,
collect_installed_distribution_versions,
get_requirement_check_paths,
)
logger = logging.getLogger("astrbot")
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
if core_dist_name:
try:
importlib_metadata.distribution(core_dist_name)
return core_dist_name
except importlib_metadata.PackageNotFoundError:
return None
try:
importlib_metadata.distribution("AstrBot")
return "AstrBot"
except importlib_metadata.PackageNotFoundError:
pass
if not __package__:
return None
top_pkg = __package__.split(".")[0]
for dist in importlib_metadata.distributions():
try:
top_level = dist.read_text("top_level.txt") or ""
except Exception:
continue
if top_pkg in top_level.splitlines():
if "Name" in dist.metadata:
return dist.metadata["Name"]
return None
@functools.cache
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
try:
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
except Exception as exc:
logger.warning("解析核心分发名称失败: %s", exc)
return ()
if not resolved_core_dist_name:
return ()
try:
dist = importlib_metadata.distribution(resolved_core_dist_name)
except importlib_metadata.PackageNotFoundError:
return ()
except Exception as exc:
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
return ()
if not dist or not dist.requires:
return ()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if not installed:
return ()
constraints: list[str] = []
for req_str in dist.requires:
try:
req = Requirement(req_str)
if req.marker and not req.marker.evaluate():
continue
name = canonicalize_distribution_name(req.name)
if name in installed:
constraints.append(f"{name}=={installed[name]}")
except Exception:
continue
return tuple(constraints)
class CoreConstraintsProvider:
def __init__(self, core_dist_name: str | None) -> None:
self._core_dist_name = core_dist_name
@contextlib.contextmanager
def constraints_file(self) -> Iterator[str | None]:
constraints = _get_core_constraints(self._core_dist_name)
if not constraints:
yield None
return
path: str | None = None
try:
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
) as f:
f.write("\n".join(constraints))
path = f.name
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
except Exception as exc:
logger.warning("创建临时约束文件失败: %s", exc)
yield None
return
try:
yield path
finally:
if path and os.path.exists(path):
with contextlib.suppress(Exception):
os.remove(path)
+1 -7
View File
@@ -14,7 +14,7 @@ import certifi
import psutil
from PIL import Image
from .astrbot_path import get_astrbot_data_path, get_astrbot_path, get_astrbot_temp_path
from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
logger = logging.getLogger("astrbot")
@@ -219,13 +219,7 @@ def get_local_ip_addresses():
async def get_dashboard_version():
# First check user data directory (manually updated / downloaded dashboard).
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
if not os.path.exists(dist_dir):
# Fall back to the dist bundled inside the installed wheel.
_bundled = Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
if _bundled.exists():
dist_dir = str(_bundled)
if os.path.exists(dist_dir):
version_file = os.path.join(dist_dir, "assets", "version")
if os.path.exists(version_file):
+103 -435
View File
@@ -7,71 +7,21 @@ import io
import logging
import os
import re
import shlex
import sys
import threading
from collections import deque
from dataclasses import dataclass
from urllib.parse import urlparse
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name as _canonicalize_distribution_name,
)
from astrbot.core.utils.requirements_utils import (
extract_requirement_name,
extract_requirement_names,
parse_package_install_input,
)
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
_PIP_FAILURE_PATTERNS = {
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
}
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
)
_MAX_PIP_OUTPUT_LINES = 200
class DependencyConflictError(Exception):
"""Raised when pip encounters a dependency conflict."""
def __init__(
self, message: str, errors: list[str], *, is_core_conflict: bool
) -> None:
super().__init__(message)
self.errors = errors
self.is_core_conflict = is_core_conflict
class PipInstallError(Exception):
"""Raised when pip install fails without a classified dependency conflict."""
def __init__(self, message: str, *, code: int) -> None:
super().__init__(message)
self.code = code
@dataclass
class PipConflictContext:
relevant_lines: list[str]
requested_lines: list[str]
dependency_detail_lines: list[str]
constraint_lines: list[str]
has_strong_conflict_signal: bool
has_contextual_conflict_signal: bool
def _canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
def _get_pip_main():
@@ -91,12 +41,11 @@ def _get_pip_main():
return pip_main
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
sys.path.insert(0, normalized_target)
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
stream = io.StringIO()
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
result_code = pip_main(args)
return result_code, stream.getvalue()
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
@@ -110,258 +59,76 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
handler.close()
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
host = parsed.hostname
if host == "mirrors.aliyun.com":
return host
return None
def _normalize_sensitive_pip_key(raw_key: str) -> str:
return raw_key.lstrip("-").replace("-", "_").lower()
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
def _redact_url_credentials(raw_value: str) -> str:
"""Redact URL credentials and known inline secret values for safe logging."""
parsed = urlparse(raw_value)
if parsed.netloc and "@" in parsed.netloc:
hostname = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
if raw_value.startswith("--"):
option, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(option):
return f"{option}=****"
return raw_value
key, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(key):
return f"{key}=****"
return raw_value
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
redacted_args: list[str] = []
redact_next_value = False
for arg in args:
if redact_next_value:
redacted_args.append("****")
redact_next_value = False
continue
if arg.startswith("--") and "=" in arg:
option, value = arg.split("=", 1)
if _is_sensitive_pip_value_key(option):
redacted_args.append(f"{option}=****")
else:
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
continue
if arg.startswith("-i") and arg != "-i":
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
continue
if _is_sensitive_pip_value_key(arg):
redacted_args.append(arg)
redact_next_value = True
continue
redacted_args.append(_redact_url_credentials(arg))
return redacted_args
def _package_specs_override_index(package_specs: list[str]) -> bool:
for index, spec in enumerate(package_specs):
if spec == "--no-index":
return True
if spec in {"-i", "--index-url"}:
if index + 1 < len(package_specs):
return True
continue
if spec.startswith("--index-url="):
return True
if spec.startswith("-i") and spec != "-i":
return True
return False
class _StreamingLogWriter(io.TextIOBase):
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
self._log_func = log_func
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
self._buffer = ""
def write(self, text: str) -> int:
if not text:
return 0
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
while "\n" in self._buffer:
raw_line, self._buffer = self._buffer.split("\n", 1)
line = raw_line.rstrip("\r\n")
self._log_func(line)
self._lines.append(line)
return len(text)
def flush(self) -> None:
line = self._buffer.rstrip("\r\n")
if line:
self._log_func(line)
self._lines.append(line)
self._buffer = ""
@property
def lines(self) -> list[str]:
return list(self._lines)
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
with (
contextlib.redirect_stdout(stream),
contextlib.redirect_stderr(stream),
):
result_code = pip_main(args)
stream.flush()
return result_code, stream.lines
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
def _normalize_conflict_detail_line(line: str) -> str:
stripped = line.strip()
if _matches_pip_failure_pattern(stripped, "user_requested"):
return re.sub(
r"^\s*The user requested\s+",
"",
stripped,
flags=re.IGNORECASE,
)
return stripped
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
matched_indices = [
index
for index, line in enumerate(output_lines)
if _matches_pip_failure_pattern(line)
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
if matched_indices:
relevant_index_set: set[int] = set()
for index in matched_indices:
start = max(0, index - 1)
end = min(len(output_lines), index + 2)
relevant_index_set.update(range(start, end))
relevant_output_lines = [
line
for index, line in enumerate(output_lines)
if index in relevant_index_set
]
else:
relevant_output_lines = output_lines[-5:]
sys.path.insert(0, normalized_target)
if not relevant_output_lines:
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
)
except ValueError:
return False
def _extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
if line.startswith("-"):
return None
dependency_detail_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "dependency_detail")
]
requested_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "user_requested")
and not _matches_pip_failure_pattern(line, "constraint")
]
if not requested_lines:
requested_lines = [
line
for line in dependency_detail_lines
if not _matches_pip_failure_pattern(line, "constraint")
]
constraint_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "constraint")
]
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return _canonicalize_distribution_name(egg_match.group(1))
has_strong_conflict_signal = any(
_matches_pip_failure_pattern(
line,
"resolution_impossible",
"cannot_install",
)
for line in relevant_output_lines
)
has_contextual_conflict_signal = any(
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
return PipConflictContext(
relevant_lines=relevant_output_lines,
requested_lines=requested_lines,
dependency_detail_lines=dependency_detail_lines,
constraint_lines=constraint_lines,
has_strong_conflict_signal=has_strong_conflict_signal,
has_contextual_conflict_signal=has_contextual_conflict_signal,
)
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
context = _build_pip_conflict_context(output_lines)
if context is None:
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
return None
return _canonicalize_distribution_name(candidate)
if (
not context.has_strong_conflict_signal
and not context.has_contextual_conflict_signal
and not (context.requested_lines and context.constraint_lines)
):
return None
is_core_conflict = bool(context.constraint_lines)
detail = ""
if context.constraint_lines and context.requested_lines:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}"
)
elif len(context.dependency_detail_lines) >= 2:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}"
)
if is_core_conflict:
message = (
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
)
else:
message = f"检测到依赖冲突。{detail}"
return DependencyConflictError(
message,
context.relevant_lines,
is_core_conflict=is_core_conflict,
)
def _extract_requirement_names(requirements_path: str) -> set[str]:
names: set[str] = set()
try:
with open(requirements_path, encoding="utf-8") as requirements_file:
for line in requirements_file:
requirement_name = _extract_requirement_name(line)
if requirement_name:
names.add(requirement_name)
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return names
def _extract_top_level_modules(
@@ -388,11 +155,7 @@ def _collect_candidate_modules(
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
try:
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
distribution_name = (
distribution.metadata["Name"]
if "Name" in distribution.metadata
else None
)
distribution_name = distribution.metadata.get("Name")
if not distribution_name:
continue
canonical_name = _canonicalize_distribution_name(distribution_name)
@@ -410,7 +173,7 @@ def _collect_candidate_modules(
for distribution in by_name.get(requirement_name, []):
for dependency_line in distribution.requires or []:
dependency_name = extract_requirement_name(dependency_line)
dependency_name = _extract_requirement_name(dependency_line)
if not dependency_name:
continue
if dependency_name in expanded_requirement_names:
@@ -467,38 +230,6 @@ def _ensure_preferred_modules(
raise RuntimeError(conflict_message)
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
)
except ValueError:
return False
def _prefer_module_from_site_packages(
module_name: str, site_packages_path: str
) -> bool:
@@ -800,63 +531,9 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
class PipInstaller:
def __init__(
self,
pip_install_arg: str,
pypi_index_url: str | None = None,
core_dist_name: str | None = "AstrBot",
) -> None:
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url
self.core_dist_name = core_dist_name
self._core_constraints = CoreConstraintsProvider(core_dist_name)
def _build_pip_args(
self,
package_name: str | None,
requirements_path: str | None,
mirror: str | None,
) -> tuple[list[str], set[str]]:
args: list[str] = []
requested_requirements: set[str] = set()
normalized_requirements_path = (
requirements_path.strip() if requirements_path else ""
)
if package_name and normalized_requirements_path:
raise ValueError(
"package_name and requirements_path cannot be used together"
)
if package_name:
parsed_package = parse_package_install_input(package_name)
if parsed_package.specs:
args = ["install", *parsed_package.specs]
requested_requirements = set(parsed_package.requirement_names)
elif normalized_requirements_path:
args = ["install", "-r", normalized_requirements_path]
requested_requirements = extract_requirement_names(
normalized_requirements_path
)
if not args:
return [], requested_requirements
pip_install_args = (
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
)
if not _package_specs_override_index([*args[1:], *pip_install_args]):
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
trusted_host = _get_trusted_host_for_index_url(index_url)
if trusted_host:
args.extend(["--trusted-host", trusted_host])
args.extend(["-i", index_url])
if pip_install_args:
args.extend(pip_install_args)
return args, requested_requirements
async def install(
self,
@@ -864,37 +541,36 @@ class PipInstaller:
requirements_path: str | None = None,
mirror: str | None = None,
) -> None:
args, requested_requirements = self._build_pip_args(
package_name, requirements_path, mirror
)
if not args:
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
return
args = ["install"]
requested_requirements: set[str] = set()
if package_name:
args.append(package_name)
requirement_name = _extract_requirement_name(package_name)
if requirement_name:
requested_requirements.add(requirement_name)
elif requirements_path:
args.extend(["-r", requirements_path])
requested_requirements = _extract_requirement_names(requirements_path)
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
args.extend(
[
"--target",
target_site_packages,
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
args.extend(["--target", target_site_packages])
args.extend(["--upgrade", "--force-reinstall"])
with self._core_constraints.constraints_file() as constraints_file_path:
if constraints_file_path:
args.extend(["-c", constraints_file_path])
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
logger.info(
"Pip 包管理器 argv: %s",
["pip", *_redact_pip_args_for_logging(args)],
)
await self._run_pip_with_classification(args)
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
if target_site_packages:
_prepend_sys_path(target_site_packages)
@@ -913,7 +589,7 @@ class PipInstaller:
if not os.path.isdir(target_site_packages):
return
requested_requirements = extract_requirement_names(requirements_path)
requested_requirements = _extract_requirement_names(requirements_path)
if not requested_requirements:
return
@@ -929,21 +605,13 @@ class PipInstaller:
_patch_distlib_finder_for_frozen_runtime()
original_handlers = list(logging.getLogger().handlers)
try:
result_code, output_lines = await asyncio.to_thread(
_run_pip_main_streaming, pip_main, args
)
finally:
_cleanup_added_root_handlers(original_handlers)
if result_code != 0:
conflict = _classify_pip_failure(output_lines)
if conflict:
raise conflict
result_code, output = await asyncio.to_thread(
_run_pip_main_with_output, pip_main, args
)
for line in output.splitlines():
line = line.strip()
if line:
logger.info(line)
_cleanup_added_root_handlers(original_handlers)
return result_code
async def _run_pip_with_classification(self, args: list[str]) -> None:
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
-408
View File
@@ -1,408 +0,0 @@
import importlib.metadata as importlib_metadata
import logging
import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
class RequirementsPrecheckFailed(Exception):
"""Raised when the pre-check of requirements fails."""
pass
@dataclass(frozen=True)
class ParsedPackageInput:
specs: tuple[str, ...]
requirement_names: frozenset[str]
def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
def strip_inline_requirement_comment(raw_input: str) -> str:
if raw_input.lstrip().startswith("#"):
return ""
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
try:
parsed_version = Version(version)
except InvalidVersion:
return False
return specifier.contains(parsed_version, prereleases=True)
def _looks_like_local_path_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return candidate in {".", ".."} or candidate.startswith(
("./", "../", "/", "~/", ".\\", "..\\", "\\")
)
def looks_like_direct_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return (
_looks_like_local_path_reference(candidate)
or candidate.startswith("git+")
or "://" in candidate
)
def extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return canonicalize_distribution_name(egg_match.group(1))
if line.startswith("-"):
return None
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
return None
return canonicalize_distribution_name(candidate)
def _parse_editable_or_direct_name(target: str) -> str | None:
name = extract_requirement_name(target)
if not name:
return None
if "#egg=" in target or not looks_like_direct_reference(target):
return name
return None
def _parse_requirement_name_and_spec(
line: str,
) -> tuple[str | None, SpecifierSet | None]:
if line.startswith(("-c", "--constraint")):
return None, None
try:
req = Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
return None, None
editable_target: str | None = None
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
editable_target = tokens[1]
elif tokens[0].startswith("--editable="):
editable_target = tokens[0].split("=", 1)[1]
if editable_target:
name = _parse_editable_or_direct_name(editable_target)
return (name, None) if name else (None, None)
name = _parse_editable_or_direct_name(line)
return (name, None) if name else (None, None)
if req.marker and not req.marker.evaluate():
return None, None
return canonicalize_distribution_name(req.name), (req.specifier or None)
def _parse_requirement_line(
line: str,
) -> tuple[str, SpecifierSet | None] | None:
name, specifier = _parse_requirement_name_and_spec(line)
return (name, specifier) if name else None
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
requirement_names: set[str] = set()
skip_next_for: str | None = None
for token in tokens:
if skip_next_for:
if skip_next_for == "editable":
name = _parse_editable_or_direct_name(token)
if name:
requirement_names.add(name)
skip_next_for = None
continue
if token in {"-e", "--editable"}:
skip_next_for = "editable"
continue
if token in {
"-i",
"--index-url",
"--extra-index-url",
"-f",
"--find-links",
"--trusted-host",
"-r",
"--requirement",
"-c",
"--constraint",
}:
skip_next_for = "option-value"
continue
if token.startswith(("--editable=",)):
editable_target = token.split("=", 1)[1]
name = _parse_editable_or_direct_name(editable_target)
if name:
requirement_names.add(name)
continue
if token.startswith(
(
"--index-url=",
"--extra-index-url=",
"--find-links=",
"--trusted-host=",
"--requirement=",
"--constraint=",
)
):
continue
if (
(token.startswith("-i") and token != "-i")
or (token.startswith("-f") and token != "-f")
or token == "--no-index"
):
continue
if token.startswith("-"):
continue
name, _ = _parse_requirement_name_and_spec(token)
if name:
requirement_names.add(name)
return frozenset(requirement_names)
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
specs: list[str] = []
requirement_names: set[str] = set()
normalized = raw_input.strip()
if not normalized:
return ParsedPackageInput(specs=(), requirement_names=frozenset())
for raw_line in normalized.splitlines():
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
try:
Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
continue
specs.extend(tokens)
requirement_names.update(
_extract_requirement_names_from_package_tokens(tokens)
)
continue
specs.append(line)
name, _ = _parse_requirement_name_and_spec(line)
if name:
requirement_names.add(name)
return ParsedPackageInput(
specs=tuple(specs),
requirement_names=frozenset(requirement_names),
)
def _iter_requirement_lines(
requirements_path: str,
_visited: set[str] | None = None,
) -> Iterator[str]:
visited = _visited or set()
resolved_path = os.path.realpath(requirements_path)
if resolved_path in visited:
logger.warning(
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
)
return
visited.add(resolved_path)
with open(resolved_path, encoding="utf-8") as f:
for raw_line in f:
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
tokens = shlex.split(line)
if not tokens:
continue
nested: str | None = None
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
nested = tokens[1]
elif tokens[0].startswith("--requirement="):
nested = tokens[0].split("=", 1)[1]
if nested:
if not os.path.isabs(nested):
nested = os.path.join(os.path.dirname(resolved_path), nested)
yield from _iter_requirement_lines(nested, _visited=visited)
continue
yield line
def iter_requirements(
requirements_path: str | None = None,
lines: Iterable[str] | None = None,
) -> Iterator[tuple[str, SpecifierSet | None]]:
if lines is None:
if requirements_path is None:
raise ValueError("Either requirements_path or lines must be provided")
lines = _iter_requirement_lines(requirements_path)
for line in lines:
parsed = _parse_requirement_line(line)
if parsed is not None:
yield parsed
def extract_requirement_names(requirements_path: str) -> set[str]:
try:
return {
name for name, _ in iter_requirements(requirements_path=requirements_path)
}
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return set()
def get_requirement_check_paths() -> list[str]:
paths = list(sys.path)
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
if os.path.isdir(target_site_packages):
paths.insert(0, target_site_packages)
return paths
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
distribution_name = (
distribution.metadata["Name"] if "Name" in distribution.metadata else None
)
if not distribution_name:
return None, None
return canonicalize_distribution_name(distribution_name), distribution.version
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
installed: dict[str, str] = {}
try:
for distribution in importlib_metadata.distributions(path=paths):
distribution_name, version = _canonical_distribution_identity(distribution)
if not distribution_name or not version:
continue
installed.setdefault(distribution_name, version)
except Exception as exc:
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
return None
return installed
def _load_requirement_lines_for_precheck(
requirements_path: str,
) -> tuple[bool, list[str] | None]:
try:
requirement_lines = list(_iter_requirement_lines(requirements_path))
except Exception as exc:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
requirements_path,
exc,
)
return False, None
fallback_line = next(
(
line
for line in requirement_lines
if (
(
line.startswith(("-e ", "--editable ", "--editable="))
and "#egg=" not in line
)
or (
_parse_requirement_line(line) is None
and looks_like_direct_reference(line)
)
)
),
None,
)
if fallback_line is not None:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
requirements_path,
fallback_line,
)
return False, None
return True, requirement_lines
def find_missing_requirements(requirements_path: str) -> set[str] | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if installed is None:
return None
missing: set[str] = set()
for name, specifier in required:
installed_version = installed.get(name)
if not installed_version:
missing.add(name)
continue
if specifier and not _specifier_contains_version(specifier, installed_version):
missing.add(name)
return missing
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path)
if missing is None:
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
return missing
+1 -1
View File
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
def is_packaged_desktop_runtime() -> bool:
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
+1 -27
View File
@@ -1,13 +1,9 @@
import asyncio
import threading
import weakref
from collections import defaultdict
from contextlib import asynccontextmanager
class _PerLoopSessionLockManager:
"""Per-event-loop session lock manager; keeps original simple semantics."""
class SessionLockManager:
def __init__(self) -> None:
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._lock_count: dict[str, int] = defaultdict(int)
@@ -30,26 +26,4 @@ class _PerLoopSessionLockManager:
self._lock_count.pop(session_id, None)
class SessionLockManager:
"""Thread-safe session lock manager with per-event-loop isolation."""
def __init__(self) -> None:
self._state_guard = threading.Lock()
self._loop_managers: weakref.WeakKeyDictionary[
asyncio.AbstractEventLoop, _PerLoopSessionLockManager
] = weakref.WeakKeyDictionary()
def _get_loop_manager(self) -> _PerLoopSessionLockManager:
"""Get the lock manager for the current event loop."""
loop = asyncio.get_running_loop()
with self._state_guard:
return self._loop_managers.setdefault(loop, _PerLoopSessionLockManager())
@asynccontextmanager
async def acquire_lock(self, session_id: str):
manager = self._get_loop_manager()
async with manager.acquire_lock(session_id):
yield
session_lock_manager = SessionLockManager()
-2
View File
@@ -610,7 +610,6 @@ class ConfigRoute(Route):
try:
conf_id = self.acm.create_conf(name=name, config=config)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
@@ -650,7 +649,6 @@ class ConfigRoute(Route):
try:
success = self.acm.delete_conf(conf_id)
if success:
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
return Response().ok(message="删除成功").__dict__
return Response().error("删除失败").__dict__
except ValueError as e:
+1 -31
View File
@@ -5,8 +5,7 @@ import os
import ssl
import traceback
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from datetime import datetime
import aiohttp
import certifi
@@ -353,34 +352,6 @@ class PluginRoute(Route):
logger.warning(f"获取插件 Logo 失败: {e}")
return None
def _resolve_plugin_dir(self, plugin) -> Path | None:
if not plugin.root_dir_name:
return None
base_dir = Path(
self.plugin_manager.reserved_plugin_path
if plugin.reserved
else self.plugin_manager.plugin_store_path
)
plugin_dir = base_dir / plugin.root_dir_name
if not plugin_dir.is_dir():
return None
return plugin_dir
def _get_plugin_installed_at(self, plugin) -> str | None:
plugin_dir = self._resolve_plugin_dir(plugin)
if plugin_dir is None:
return None
try:
return datetime.fromtimestamp(
plugin_dir.stat().st_mtime,
timezone.utc,
).isoformat()
except OSError as exc:
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
return None
async def get_plugins(self):
_plugin_resp = []
plugin_name = request.args.get("name")
@@ -406,7 +377,6 @@ class PluginRoute(Route):
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": self._get_plugin_installed_at(plugin),
}
# 检查是否为全空的幽灵插件
if not any(
-110
View File
@@ -2,7 +2,6 @@ import os
import re
import shutil
import traceback
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -51,7 +50,6 @@ class SkillsRoute(Route):
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
"/skills/batch-upload": ("POST", self.batch_upload_skills),
"/skills/download": ("GET", self.download_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
@@ -190,114 +188,6 @@ class SkillsRoute(Route):
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
async def batch_upload_skills(self):
"""批量上传多个 skill ZIP 文件"""
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
files = await request.files
file_list = files.getlist("files")
if not file_list:
return Response().error("No files provided").__dict__
succeeded = []
failed = []
skill_mgr = SkillManager()
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
for file in file_list:
filename = os.path.basename(file.filename or "unknown.zip")
temp_path = None
try:
if not filename.lower().endswith(".zip"):
failed.append(
{
"filename": filename,
"error": "Only .zip files are supported",
}
)
continue
temp_path = os.path.join(
temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
)
await file.save(temp_path)
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=True
)
succeeded.append({"filename": filename, "name": skill_name})
except Exception as e:
failed.append({"filename": filename, "error": str(e)})
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception:
pass
if succeeded:
try:
await sync_skills_to_active_sandboxes()
except Exception:
logger.warning(
"Failed to sync uploaded skills to active sandboxes."
)
total = len(file_list)
success_count = len(succeeded)
if success_count == total:
message = f"All {total} skill(s) uploaded successfully."
return (
Response()
.ok(
{
"total": total,
"succeeded": succeeded,
"failed": failed,
},
message,
)
.__dict__
)
if success_count == 0:
message = f"Upload failed for all {total} file(s)."
resp = Response().error(message)
resp.data = {
"total": total,
"succeeded": succeeded,
"failed": failed,
}
return resp.__dict__
message = f"Partial success: {success_count}/{total} skill(s) uploaded."
return (
Response()
.ok(
{
"total": total,
"succeeded": succeeded,
"failed": failed,
},
message,
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def download_skill(self):
try:
name = str(request.args.get("name") or "").strip()
+75 -190
View File
@@ -12,32 +12,6 @@ from .route import Response, Route, RouteContext
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
class EmptyMcpServersError(ValueError):
"""Raised when mcpServers is empty."""
pass
def _extract_mcp_server_config(mcp_servers_value: object) -> dict:
"""Extract server configuration from user-submitted mcpServers field.
Raises:
ValueError: Invalid configuration
"""
if not isinstance(mcp_servers_value, dict):
raise ValueError("mcpServers must be a JSON object")
if not mcp_servers_value:
raise EmptyMcpServersError("mcpServers configuration cannot be empty")
key_0 = next(iter(mcp_servers_value))
extracted = mcp_servers_value[key_0]
if not isinstance(extracted, dict):
raise ValueError(
"Invalid mcpServers format. Ensure each key in mcpServers is a server name, "
"and each value is an object containing fields like command/url."
)
return extracted
class ToolsRoute(Route):
def __init__(
self,
@@ -59,37 +33,13 @@ class ToolsRoute(Route):
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
def _rollback_mcp_server(self, name: str) -> bool:
try:
rollback_config = self.tool_mgr.load_mcp_config()
if name in rollback_config["mcpServers"]:
rollback_config["mcpServers"].pop(name)
return self.tool_mgr.save_mcp_config(rollback_config)
return True
except Exception:
logger.error(traceback.format_exc())
return False
async def get_mcp_servers(self):
try:
config = self.tool_mgr.load_mcp_config()
servers = []
mcp_servers = config.get("mcpServers", {})
if not isinstance(mcp_servers, dict):
logger.warning(
f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers."
)
mcp_servers = {}
# 获取所有服务器并添加它们的工具列表
for name, server_config in mcp_servers.items():
if not isinstance(server_config, dict):
logger.warning(
f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped."
)
continue
for name, server_config in config["mcpServers"].items():
server_info = {
"name": name,
"active": server_config.get("active", True),
@@ -101,9 +51,11 @@ class ToolsRoute(Route):
server_info[key] = value
# 如果MCP客户端已初始化,从客户端获取工具名称
for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items():
for (
name_key,
mcp_client,
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
mcp_client = runtime.client
server_info["tools"] = [tool.name for tool in mcp_client.tools]
server_info["errlogs"] = mcp_client.server_errlogs
break
@@ -115,7 +67,7 @@ class ToolsRoute(Route):
return Response().ok(servers).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to get MCP server list: {e!s}").__dict__
return Response().error(f"获取 MCP 服务器列表失败: {e!s}").__dict__
async def add_mcp_server(self):
try:
@@ -125,7 +77,7 @@ class ToolsRoute(Route):
# 检查必填字段
if not name:
return Response().error("Server name cannot be empty").__dict__
return Response().error("服务器名称不能为空").__dict__
# 移除特殊字段并检查配置是否有效
has_valid_config = False
@@ -135,33 +87,21 @@ class ToolsRoute(Route):
for key, value in server_data.items():
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
if key == "mcpServers":
try:
server_config = _extract_mcp_server_config(
server_data["mcpServers"]
)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
has_valid_config = True
if not has_valid_config:
return (
Response()
.error("A valid server configuration is required")
.__dict__
)
return Response().error("必须提供有效的服务器配置").__dict__
config = self.tool_mgr.load_mcp_config()
if name in config["mcpServers"]:
return Response().error(f"Server {name} already exists").__dict__
try:
await self.tool_mgr.test_mcp_server_connection(server_config)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"MCP connection test failed: {e!s}").__dict__
return Response().error(f"服务器 {name} 已存在").__dict__
config["mcpServers"][name] = server_config
@@ -173,27 +113,17 @@ class ToolsRoute(Route):
timeout=30,
)
except TimeoutError:
rollback_ok = self._rollback_mcp_server(name)
err_msg = f"Timed out while enabling MCP server {name}."
if not rollback_ok:
err_msg += " Configuration rollback failed. Please check the config manually."
return Response().error(err_msg).__dict__
return Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
except Exception as e:
logger.error(traceback.format_exc())
rollback_ok = self._rollback_mcp_server(name)
err_msg = f"Failed to enable MCP server {name}: {e!s}"
if not rollback_ok:
err_msg += " Configuration rollback failed. Please check the config manually."
return Response().error(err_msg).__dict__
return (
Response()
.ok(None, f"Successfully added MCP server {name}")
.__dict__
)
return Response().error("Failed to save configuration").__dict__
return (
Response().error(f"启用 MCP 服务器 {name} 失败: {e!s}").__dict__
)
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to add MCP server: {e!s}").__dict__
return Response().error(f"添加 MCP 服务器失败: {e!s}").__dict__
async def update_mcp_server(self):
try:
@@ -203,25 +133,23 @@ class ToolsRoute(Route):
old_name = server_data.get("oldName") or name
if not name:
return Response().error("Server name cannot be empty").__dict__
return Response().error("服务器名称不能为空").__dict__
config = self.tool_mgr.load_mcp_config()
if old_name not in config["mcpServers"]:
return Response().error(f"Server {old_name} does not exist").__dict__
return Response().error(f"服务器 {old_name} 不存在").__dict__
is_rename = name != old_name
if name in config["mcpServers"] and is_rename:
return Response().error(f"Server {name} already exists").__dict__
return Response().error(f"服务器 {name} 已存在").__dict__
# 获取活动状态
old_config = config["mcpServers"][old_name]
if isinstance(old_config, dict):
old_active = old_config.get("active", True)
else:
old_active = True
active = server_data.get("active", old_active)
active = server_data.get(
"active",
config["mcpServers"][old_name].get("active", True),
)
# 创建新的配置对象
server_config = {"active": active}
@@ -239,19 +167,17 @@ class ToolsRoute(Route):
"oldName",
]: # 排除特殊字段
if key == "mcpServers":
try:
server_config = _extract_mcp_server_config(
server_data["mcpServers"]
)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
key_0 = list(server_data["mcpServers"].keys())[
0
] # 不考虑为空的情况
server_config = server_data["mcpServers"][key_0]
else:
server_config[key] = value
only_update_active = False
# 如果只更新活动状态,保留原始配置
if only_update_active and isinstance(old_config, dict):
for key, value in old_config.items():
if only_update_active:
for key, value in config["mcpServers"][old_name].items():
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
@@ -266,7 +192,7 @@ class ToolsRoute(Route):
# 处理MCP客户端状态变化
if active:
if (
old_name in self.tool_mgr.mcp_server_runtime_view
old_name in self.tool_mgr.mcp_client_dict
or not only_update_active
or is_rename
):
@@ -276,7 +202,7 @@ class ToolsRoute(Route):
return (
Response()
.error(
f"Timed out while disabling MCP server {old_name} before enabling: {e!s}"
f"启用前停用 MCP 服务器时 {old_name} 超时: {e!s}"
)
.__dict__
)
@@ -285,7 +211,7 @@ class ToolsRoute(Route):
return (
Response()
.error(
f"Failed to disable MCP server {old_name} before enabling: {e!s}"
f"启用前停用 MCP 服务器时 {old_name} 失败: {e!s}"
)
.__dict__
)
@@ -297,44 +223,38 @@ class ToolsRoute(Route):
)
except TimeoutError:
return (
Response()
.error(f"Timed out while enabling MCP server {name}.")
.__dict__
Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return (
Response()
.error(f"Failed to enable MCP server {name}: {e!s}")
.error(f"启用 MCP 服务器 {name} 失败: {e!s}")
.__dict__
)
# 如果要停用服务器
elif old_name in self.tool_mgr.mcp_server_runtime_view:
elif old_name in self.tool_mgr.mcp_client_dict:
try:
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
except TimeoutError:
return (
Response()
.error(f"Timed out while disabling MCP server {old_name}.")
.error(f"停用 MCP 服务器 {old_name} 超时。")
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return (
Response()
.error(f"Failed to disable MCP server {old_name}: {e!s}")
.error(f"停用 MCP 服务器 {old_name} 失败: {e!s}")
.__dict__
)
return (
Response()
.ok(None, f"Successfully updated MCP server {name}")
.__dict__
)
return Response().error("Failed to save configuration").__dict__
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to update MCP server: {e!s}").__dict__
return Response().error(f"更新 MCP 服务器失败: {e!s}").__dict__
async def delete_mcp_server(self):
try:
@@ -342,91 +262,66 @@ class ToolsRoute(Route):
name = server_data.get("name", "")
if not name:
return Response().error("Server name cannot be empty").__dict__
return Response().error("服务器名称不能为空").__dict__
config = self.tool_mgr.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"Server {name} does not exist").__dict__
return Response().error(f"服务器 {name} 不存在").__dict__
del config["mcpServers"][name]
if self.tool_mgr.save_mcp_config(config):
if name in self.tool_mgr.mcp_server_runtime_view:
if name in self.tool_mgr.mcp_client_dict:
try:
await self.tool_mgr.disable_mcp_server(name, timeout=10)
except TimeoutError:
return (
Response()
.error(f"Timed out while disabling MCP server {name}.")
.__dict__
Response().error(f"停用 MCP 服务器 {name} 超时。").__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return (
Response()
.error(f"Failed to disable MCP server {name}: {e!s}")
.error(f"停用 MCP 服务器 {name} 失败: {e!s}")
.__dict__
)
return (
Response()
.ok(None, f"Successfully deleted MCP server {name}")
.__dict__
)
return Response().error("Failed to save configuration").__dict__
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to delete MCP server: {e!s}").__dict__
return Response().error(f"删除 MCP 服务器失败: {e!s}").__dict__
async def test_mcp_connection(self):
"""Test MCP server connection."""
"""测试 MCP 服务器连接"""
try:
server_data = await request.json
config = server_data.get("mcp_server_config", None)
if not isinstance(config, dict) or not config:
return Response().error("Invalid MCP server configuration").__dict__
return Response().error("无效的 MCP 服务器配置").__dict__
if "mcpServers" in config:
mcp_servers = config["mcpServers"]
if isinstance(mcp_servers, dict) and len(mcp_servers) > 1:
return (
Response()
.error(
"Only one MCP server configuration can be tested at a time"
)
.__dict__
)
try:
config = _extract_mcp_server_config(mcp_servers)
except EmptyMcpServersError:
return (
Response()
.error("MCP server configuration cannot be empty")
.__dict__
)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
keys = list(config["mcpServers"].keys())
if not keys:
return Response().error("MCP 服务器配置不能为空").__dict__
if len(keys) > 1:
return Response().error("一次只能配置一个 MCP 服务器配置").__dict__
config = config["mcpServers"][keys[0]]
elif not config:
return (
Response()
.error("MCP server configuration cannot be empty")
.__dict__
)
return Response().error("MCP 服务器配置不能为空").__dict__
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
return (
Response()
.ok(data=tools_name, message="🎉 MCP server is available!")
.__dict__
Response().ok(data=tools_name, message="🎉 MCP 服务器可用!").__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to test MCP connection: {e!s}").__dict__
return Response().error(f"测试 MCP 连接失败: {e!s}").__dict__
async def get_tool_list(self):
"""Get all registered tools."""
"""获取所有注册的工具列表"""
try:
tools = self.tool_mgr.func_list
tools_dict = []
@@ -456,44 +351,36 @@ class ToolsRoute(Route):
return Response().ok(data=tools_dict).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to get tool list: {e!s}").__dict__
return Response().error(f"获取工具列表失败: {e!s}").__dict__
async def toggle_tool(self):
"""Activate or deactivate a specified tool."""
"""启用或停用指定的工具"""
try:
data = await request.json
tool_name = data.get("name")
action = data.get("activate") # True or False
if not tool_name or action is None:
return (
Response()
.error("Missing required parameters: name or activate")
.__dict__
)
return Response().error("缺少必要参数: name 或 action").__dict__
if action:
try:
ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map)
except ValueError as e:
return Response().error(f"Failed to activate tool: {e!s}").__dict__
return Response().error(f"启用工具失败: {e!s}").__dict__
else:
ok = self.tool_mgr.deactivate_llm_tool(tool_name)
if ok:
return Response().ok(None, "Operation successful.").__dict__
return (
Response()
.error(f"Tool {tool_name} does not exist or the operation failed.")
.__dict__
)
return Response().ok(None, "操作成功。").__dict__
return Response().error(f"工具 {tool_name} 不存在或操作失败。").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to operate tool: {e!s}").__dict__
return Response().error(f"操作工具失败: {e!s}").__dict__
async def sync_provider(self):
"""Sync MCP provider configuration."""
"""同步 MCP 提供者配置"""
try:
data = await request.json
provider_name = data.get("name") # modelscope, or others
@@ -502,11 +389,9 @@ class ToolsRoute(Route):
access_token = data.get("access_token", "")
await self.tool_mgr.sync_modelscope_mcp_servers(access_token)
case _:
return (
Response().error(f"Unknown provider: {provider_name}").__dict__
)
return Response().error(f"未知: {provider_name}").__dict__
return Response().ok(message="Sync completed").__dict__
return Response().ok(message="同步成功").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Sync failed: {e!s}").__dict__
return Response().error(f"同步失败: {e!s}").__dict__
+4 -16
View File
@@ -33,9 +33,6 @@ from .routes.session_management import SessionManagementRoute
from .routes.subagent import SubAgentRoute
from .routes.t2i import T2iRoute
# Static assets shipped inside the wheel (built during `hatch build`).
_BUNDLED_DIST = Path(__file__).parent / "dist"
class _AddrWithPort(Protocol):
port: int
@@ -69,22 +66,13 @@ class AstrBotDashboard:
self.config = core_lifecycle.astrbot_config
self.db = db
# Path priority:
# 1. Explicit webui_dir argument
# 2. data/dist/ (user-installed / manually updated dashboard)
# 3. astrbot/dashboard/dist/ (bundled with the wheel)
# 参数指定webui目录
if webui_dir and os.path.exists(webui_dir):
self.data_path = os.path.abspath(webui_dir)
else:
user_dist = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(user_dist):
self.data_path = os.path.abspath(user_dist)
elif _BUNDLED_DIST.exists():
self.data_path = str(_BUNDLED_DIST)
logger.info("Using bundled dashboard dist: %s", self.data_path)
else:
# Fall back to expected user path (will fail gracefully later)
self.data_path = os.path.abspath(user_dist)
self.data_path = os.path.abspath(
os.path.join(get_astrbot_data_path(), "dist"),
)
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
APP = self.app # noqa
-70
View File
@@ -1,70 +0,0 @@
## What's Changed
### 新增
- 集成 KOOK 平台适配器 ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658))。
- 新增 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))。
- 集成 DeerFlow Agent Runner 并优化流式处理 ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581))。
- 新增 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`.
-40
View File
@@ -1,40 +0,0 @@
## What's Changed
### 新增
- 新增技能 ZIP 批量上传能力 ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804))。
### 修复
- 修复 MCP Server 配置异常时可能导致崩溃的问题 ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673))。
- 修复钉钉适配器文本消息被忽略、无法主动发送文件的问题 ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921))。
- 修复钉钉适配器无法接收图片与文件的问题 ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920))。
- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913))。
- 修复 OpenRouter `api_base` 配置错误的问题 ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911))。
- 修复插件市场中按展示名搜索已安装插件不生效的问题 ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811))。
- 修复仅图片响应未应用 `reply_with_quote``reply_with_mention` 的问题 ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219))。
- 修复 `RegexFilter` 使用 `re.match` 导致匹配范围不正确的问题 ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368))。
- 修复桌面运行环境检测依赖 frozen Python 的问题 ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859))。
- 修复通过“创建新配置”创建平台机器人后找不到 pipeline scheduler 的问题 ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776))。
---
## What's Changed (EN)
### New Features
- Added batch upload support for multiple skill ZIP files ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804)).
### Bug Fixes
- Fixed potential crash on malformed MCP server config ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673)).
- Fixed DingTalk adapter issue where text messages were ignored and files could not be sent proactively ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921)).
- Fixed DingTalk adapter issue where image and file messages could not be received ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920)).
- Fixed incorrect OpenRouter `api_base` configuration ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911)).
- Fixed searching installed plugins by display name in extensions ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811)).
- Fixed image-only responses not applying `reply_with_quote` and `reply_with_mention` ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219)).
- Fixed `RegexFilter` using `re.match` instead of `re.search` for expected matching behavior ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368)).
- Fixed desktop runtime detection requiring frozen Python ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859)).
- Fixed missing pipeline scheduler after creating a platform bot via "create new config" ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776)).
- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913))
-9
View File
@@ -1,9 +0,0 @@
## What's Changed
### 新增
- 企业微信智能机器人支持长连接模式。[#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
### New
- Wecom AI Bot supports long-connection mode(Websockets). [#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
-43
View File
@@ -1,43 +0,0 @@
## What's Changed
### 新增
- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。
- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。
### 优化
- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
### 修复
- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。
- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。
- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。
### 文档
- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。
---
## What's Changed (EN)
### New Features
- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)).
- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)).
### Impprovement
- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)).
### Bug Fixes
- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)).
- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)).
- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)).
- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)).
### Documentation
- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)).
@@ -300,10 +300,6 @@ export default {
this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers')
.then(response => {
if (response.data.status === 'error') {
this.showError(response.data.message || this.tm('messages.getServersError', { error: 'Unknown error' }));
return;
}
this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => {
if (!this.mcpServerUpdateLoaders[server.name]) {
@@ -376,10 +372,6 @@ export default {
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
if (response.data.status === 'error') {
this.showError(response.data.message || this.tm('messages.saveError', { error: 'Unknown error' }));
return;
}
this.showMcpServerDialog = false;
this.addServerDialogMessage = '';
this.getServers();
@@ -1,97 +0,0 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
label: {
type: String,
required: true,
},
order: {
type: String,
default: "desc",
},
ascendingLabel: {
type: String,
default: "Ascending",
},
descendingLabel: {
type: String,
default: "Descending",
},
showOrder: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "update:order"]);
const updateSortBy = (value) => {
emit("update:modelValue", value);
};
const toggleOrder = () => {
emit("update:order", props.order === "desc" ? "asc" : "desc");
};
</script>
<template>
<div class="plugin-sort-control">
<v-select
:model-value="modelValue"
:items="items"
density="compact"
variant="outlined"
hide-details
:label="label"
class="plugin-sort-control__select"
@update:model-value="updateSortBy"
>
<template #prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<v-btn
v-if="showOrder"
icon
variant="text"
density="compact"
@click="toggleOrder"
>
<v-icon>{{
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ order === "desc" ? descendingLabel : ascendingLabel }}
</v-tooltip>
</v-btn>
</div>
</template>
<style scoped>
.plugin-sort-control {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plugin-sort-control__select {
min-width: 180px;
max-width: 220px;
}
.plugin-sort-control__select :deep(.v-field__input),
.plugin-sort-control__select :deep(.v-field-label),
.plugin-sort-control__select :deep(.v-select__selection-text),
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
font-size: 0.875rem;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
const { tm } = useModuleI18n('features/command');
@@ -53,7 +52,6 @@ const statusItems = [
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
@@ -110,11 +108,10 @@ const statusItems = [
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
@update:model-value="emit('update:searchQuery', $event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -3,7 +3,6 @@
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
import { normalizeTextInput } from '@/utils/inputValue';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
@@ -96,7 +95,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
*
*/
const filteredCommands = computed(() => {
const query = normalizeTextInput(searchQuery.value).toLowerCase();
const query = searchQuery.value.toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
@@ -185,3 +184,4 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
isGroupExpanded
};
}
@@ -15,7 +15,6 @@
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
// Composables
import { useComponentData } from './composables/useComponentData';
@@ -84,7 +83,7 @@ const {
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
@@ -254,8 +253,7 @@ watch(viewMode, async (mode) => {
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
:model-value="toolSearch"
@update:model-value="toolSearch = normalizeTextInput($event)"
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
@@ -7,7 +7,6 @@
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
@@ -162,7 +161,6 @@
<script setup>
import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
const props = defineProps({
entries: {
@@ -224,7 +222,7 @@ const emit = defineEmits([
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
set: (val) => emit('update:modelSearch', val)
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -2,7 +2,6 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
import { normalizeTextInput } from '@/utils/inputValue'
export interface UseProviderSourcesOptions {
defaultTab?: string
@@ -158,7 +157,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
})
const filteredMergedModelEntries = computed(() => {
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
const term = modelSearch.value.trim().toLowerCase()
if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => {
@@ -370,8 +370,7 @@
"hint": "Optional Discord activity name. Leave empty to disable."
},
"discord_command_register": {
"description": "Register Discord slash commands",
"hint": "When enabled, AstrBot will automatically register plugin commands as Discord slash commands"
"description": "Auto-register plugin commands as Discord slash commands"
},
"discord_proxy": {
"description": "Discord Proxy URL",
@@ -550,10 +549,6 @@
"description": "WeCom AI Bot Name",
"hint": "Must be correct; otherwise some commands won't work."
},
"wecom_ai_bot_connection_mode": {
"description": "WeCom AI Bot Connection Mode",
"hint": "Webhook mode requires Token/EncodingAESKey; long_connection mode requires BotID/Secret."
},
"wecomaibot_friend_message_welcome_text": {
"description": "WeCom AI Bot DM Welcome Message",
"hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable."
@@ -562,30 +557,6 @@
"description": "WeCom AI Bot Initial Response Text",
"hint": "First reply when the bot receives a message. Leave empty to disable."
},
"wecomaibot_token": {
"description": "WeCom AI Bot Token",
"hint": "Used for authentication in webhook callback mode."
},
"wecomaibot_encoding_aes_key": {
"description": "WeCom AI Bot EncodingAESKey",
"hint": "Used for message encryption/decryption in webhook callback mode."
},
"wecomaibot_ws_bot_id": {
"description": "Long Connection BotID",
"hint": "BotID credential for WeCom AI Bot long connection mode."
},
"wecomaibot_ws_secret": {
"description": "Long Connection Secret",
"hint": "Secret credential for WeCom AI Bot long connection mode."
},
"wecomaibot_ws_url": {
"description": "Long Connection WebSocket URL",
"hint": "Default is wss://openws.work.weixin.qq.com and usually does not need changes."
},
"wecomaibot_heartbeat_interval": {
"description": "Long Connection Heartbeat Interval",
"hint": "Heartbeat interval (seconds) in long connection mode. 30 seconds is recommended."
},
"wpp_active_message_poll": {
"description": "Enable Proactive Message Polling",
"hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default."
@@ -612,51 +583,6 @@
"only_use_webhook_url_to_send": {
"description": "Send Replies via Webhook Only",
"hint": "When enabled, all WeCom AI Bot replies are sent through msg_push_webhook_url. The message push webhook supports more message types (such as images, files, etc.). If you do not need the typing effect, it is strongly recommended to use this option. "
},
"kook_bot_token": {
"description": "Bot Token",
"type": "string",
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
},
"kook_reconnect_delay": {
"description": "Reconnect Delay",
"type": "int",
"hint": "Delay time for reconnection (seconds), using an exponential backoff strategy."
},
"kook_max_reconnect_delay": {
"description": "Max Reconnect Delay",
"type": "int",
"hint": "The maximum value for reconnection delay (seconds)."
},
"kook_max_retry_delay": {
"description": "Max Retry Delay",
"type": "int",
"hint": "The maximum delay time for retries (seconds)."
},
"kook_heartbeat_interval": {
"description": "Heartbeat Interval",
"type": "int",
"hint": "The interval time for heartbeat detection (seconds)."
},
"kook_heartbeat_timeout": {
"description": "Heartbeat Timeout",
"type": "int",
"hint": "The timeout duration for heartbeat detection (seconds)."
},
"kook_max_heartbeat_failures": {
"description": "Max Heartbeat Failures",
"type": "int",
"hint": "Maximum allowed heartbeat failures; the connection will be dropped if exceeded."
},
"kook_max_consecutive_failures": {
"description": "Max Consecutive Failures",
"type": "int",
"hint": "Maximum allowed consecutive failures; retries will stop if exceeded."
}
},
"general": {
@@ -811,17 +737,6 @@
"hint": "Telegram only supports a fixed reaction set, reference: [https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
}
}
},
"discord": {
"pre_ack_emoji": {
"enable": {
"description": "[Discord] Enable Pre-acknowledgment Emoji"
},
"emojis": {
"description": "Emoji List (Unicode or Custom Emoji Name)",
"hint": "Enter Unicode emoji symbols, e.g., 👍, 🤔, ⏳"
}
}
}
}
}
@@ -873,8 +788,7 @@
]
},
"regex": {
"description": "Segmentation Regular Expression",
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
"description": "Segmentation Regular Expression"
},
"split_words": {
"description": "Split Word List",
@@ -1522,4 +1436,4 @@
"helpMiddle": "or",
"helpSuffix": "."
}
}
}
@@ -23,9 +23,6 @@
"placeholder": "Search extensions...",
"marketPlaceholder": "Search market extensions..."
},
"filters": {
"all": "All"
},
"views": {
"card": "Card View",
"list": "List View"
@@ -125,14 +122,10 @@
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
},
"sort": {
"by": "Sort by",
"default": "Default",
"installTime": "Last Modified",
"name": "Name",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
"descending": "Descending"
},
@@ -231,43 +224,10 @@
"empty": "No Skills found",
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
"uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.",
"structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.",
"abilityMultiple": "Upload multiple zip files at once",
"abilityValidate": "Validate `SKILL.md` automatically",
"abilitySkip": "Automatically skip duplicate files.",
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
"selectFile": "Select file",
"selectFiles": "Select files (multiple allowed)",
"dropzoneTitle": "Drag multiple zip files here",
"dropzoneAction": "or click to pick multiple files from a folder",
"dropzoneHint": "Batch upload is supported and the structure will be validated automatically",
"fileListTitle": "Files in queue",
"fileListEmpty": "Selected files will appear here with validation feedback and upload status",
"uploading": "Uploading...",
"batchResultTitle": "Batch Upload Results",
"batchResultSummary": "{success} of {total} files uploaded successfully",
"batchSuccessList": "Successfully uploaded",
"batchFailedList": "Failed to upload",
"confirm": "OK",
"confirmUpload": "Start Upload",
"confirmUpload": "Upload",
"cancel": "Cancel",
"statusWaiting": "Waiting",
"statusUploading": "Uploading",
"statusSuccess": "Uploaded",
"statusError": "Failed",
"statusSkipped": "Skipped",
"summaryTotal": "{count} file(s)",
"summaryReady": "Pending {count}",
"summarySuccess": "Success {count}",
"summaryFailed": "Failed {count}",
"summarySkipped": "Skipped {count}",
"validationReady": "Ready to upload. The archive structure will be checked during upload.",
"validationZipOnly": "Only zip skill packages are supported",
"validationDuplicate": "A file with the same name is already in the queue and has been skipped",
"validationUploading": "Validating and uploading...",
"validationUploadFailed": "Upload failed. Please try again.",
"validationUploadedAs": "Installed as {name}",
"validationNoResult": "No validation result was returned. Check the platform logs.",
"noDescription": "No description",
"path": "Path",
"uploadSuccess": "Upload succeeded",
@@ -373,8 +373,7 @@
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
},
"discord_command_register": {
"description": "注册 Discord 指令",
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令"
"description": "是否自动将插件指令注册 Discord 斜杠指令"
},
"discord_proxy": {
"description": "Discord 代理地址",
@@ -543,7 +542,7 @@
},
"unified_webhook_mode": {
"description": "统一 Webhook 模式",
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
},
"webhook_uuid": {
"description": "Webhook UUID",
@@ -553,41 +552,13 @@
"description": "企业微信智能机器人的名字",
"hint": "请务必填写正确,否则无法使用一些指令。"
},
"wecom_ai_bot_connection_mode": {
"description": "企业微信智能机器人连接模式",
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey;长连接模式需要配置 BotID/Secret。"
},
"wecomaibot_friend_message_welcome_text": {
"description": "企业微信智能机器人私聊欢迎语",
"hint": "可选。当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
},
"wecomaibot_init_respond_text": {
"description": "企业微信智能机器人初始响应文本",
"hint": "可选。当机器人收到消息时,首先回复的文本内容。留空则不设置。"
},
"wecomaibot_token": {
"description": "企业微信智能机器人 Token",
"hint": "用于 Webhook 回调模式的身份验证。"
},
"wecomaibot_encoding_aes_key": {
"description": "企业微信智能机器人 EncodingAESKey",
"hint": "用于 Webhook 回调模式的消息加密解密。"
},
"wecomaibot_ws_bot_id": {
"description": "长连接 BotID",
"hint": "企业微信智能机器人长连接模式凭证 BotID。"
},
"wecomaibot_ws_secret": {
"description": "长连接 Secret",
"hint": "企业微信智能机器人长连接模式凭证 Secret。"
},
"wecomaibot_ws_url": {
"description": "长连接 WebSocket 地址",
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。"
},
"wecomaibot_heartbeat_interval": {
"description": "长连接心跳间隔",
"hint": "长连接模式心跳间隔(秒),建议 30 秒。"
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
},
"wpp_active_message_poll": {
"description": "是否启用主动消息轮询",
@@ -610,56 +581,11 @@
},
"msg_push_webhook_url": {
"description": "企业微信消息推送 Webhook URL",
"hint": "可选。用于主动消息推送,请在企微群->消息推送得到 URL。建议设置此项以带来更好的消息发送体验。"
"hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。"
},
"only_use_webhook_url_to_send": {
"description": "仅使用 Webhook 发送消息",
"hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
},
"kook_bot_token": {
"description": "机器人 Token",
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
"hint": "重连延迟时间(秒),使用指数退避策略"
},
"kook_max_reconnect_delay": {
"description": "最大重连延迟",
"type": "int",
"hint": "重连延迟的最大值(秒)"
},
"kook_max_retry_delay": {
"description": "最大重试延迟",
"type": "int",
"hint": "重试的最大延迟时间(秒)"
},
"kook_heartbeat_interval": {
"description": "心跳间隔",
"type": "int",
"hint": "心跳检测间隔时间(秒)"
},
"kook_heartbeat_timeout": {
"description": "心跳超时时间",
"type": "int",
"hint": "心跳检测超时时间(秒)"
},
"kook_max_heartbeat_failures": {
"description": "最大心跳失败次数",
"type": "int",
"hint": "允许的最大心跳失败次数,超过后断开连接"
},
"kook_max_consecutive_failures": {
"description": "最大连续失败次数",
"type": "int",
"hint": "允许的最大连续失败次数,超过后停止重试"
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
}
},
"general": {
@@ -814,17 +740,6 @@
"hint": "Telegram 仅支持固定反应集合,参考:[https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
}
}
},
"discord": {
"pre_ack_emoji": {
"enable": {
"description": "[Discord] 启用预回应表情"
},
"emojis": {
"description": "表情列表(Unicode 或自定义表情名)",
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳"
}
}
}
}
}
@@ -876,8 +791,7 @@
]
},
"regex": {
"description": "分段正则表达式",
"hint": "用于按正则规则识别分段点。建议使用能匹配分隔符的表达式。"
"description": "分段正则表达式"
},
"split_words": {
"description": "分段词列表",
@@ -1525,4 +1439,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
}
}
@@ -23,9 +23,6 @@
"placeholder": "搜索插件...",
"marketPlaceholder": "搜索市场插件..."
},
"filters": {
"all": "全部"
},
"views": {
"card": "卡片视图",
"list": "列表视图"
@@ -125,14 +122,10 @@
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
},
"sort": {
"by": "排序方式",
"default": "默认排序",
"installTime": "最后修改时间",
"name": "名称",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
"descending": "降序"
},
@@ -231,43 +224,10 @@
"empty": "暂无 Skills",
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
"abilityMultiple": "支持一次上传多个zip文件",
"abilityValidate": "自动校验 `SKILL.md`",
"abilitySkip": "自动跳过重复文件",
"uploadHint": "上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
"selectFile": "选择文件",
"selectFiles": "选择文件(可多选)",
"dropzoneTitle": "拖拽多个 zip 文件到这里",
"dropzoneAction": "或者点击之后在文件夹中选择多个文件",
"dropzoneHint": "支持批量上传,系统会自动校验目录结构",
"fileListTitle": "待处理文件",
"fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
"uploading": "正在上传...",
"batchResultTitle": "批量上传结果",
"batchResultSummary": "共 {total} 个文件,成功 {success} 个",
"batchSuccessList": "上传成功",
"batchFailedList": "上传失败",
"confirm": "确定",
"confirmUpload": "开始上传",
"confirmUpload": "上传",
"cancel": "取消",
"statusWaiting": "待上传",
"statusUploading": "上传中",
"statusSuccess": "已上传",
"statusError": "校验失败",
"statusSkipped": "已跳过",
"summaryTotal": "共 {count} 个文件",
"summaryReady": "待处理 {count}",
"summarySuccess": "成功 {count}",
"summaryFailed": "失败 {count}",
"summarySkipped": "跳过 {count}",
"validationReady": "等待上传,上传时会自动校验目录结构",
"validationZipOnly": "仅支持 zip 技能包",
"validationDuplicate": "同名文件已在列表中,已跳过",
"validationUploading": "正在校验并上传...",
"validationUploadFailed": "上传失败,请重试",
"validationUploadedAs": "已安装为 {name}",
"validationNoResult": "未收到校验结果,请检查平台日志",
"noDescription": "无描述",
"path": "路径",
"uploadSuccess": "上传成功",
@@ -27,7 +27,6 @@ const customizer = useCustomizerStore();
const theme = useTheme();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
@@ -403,29 +402,12 @@ const viewMode = computed({
});
// viewMode bot
// bot
// route bot
watch(() => route.fullPath, (newPath) => {
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
try {
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
} catch (e) {
console.error('Failed to save last bot route to localStorage:', e);
}
}
});
// viewMode
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
// chat bot bot
let lastBotRoute = '/';
try {
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
} catch (e) {
console.error('Failed to read last bot route from localStorage:', e);
if (newMode === 'bot' && oldMode === 'chat') {
// chat bot
if (route.path !== '/') {
router.push('/');
}
router.push(lastBotRoute);
}
});
@@ -38,7 +38,7 @@ const isItemActive = computed(() => {
</template>
<!-- children -->
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
<template v-for="(child, index) in item.children" :key="index">
<NavItem :item="child" :level="(level || 0) + 1" />
</template>
</v-list-group>
@@ -10,60 +10,26 @@ import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t, locale } = useI18n();
const customizer = useCustomizerStore();
function collectGroupValues(items, values = new Set()) {
items.forEach((item) => {
if (item?.children && item.title) {
values.add(item.title);
collectGroupValues(item.children, values);
}
});
return values;
}
function sanitizeOpenedItems(items, menuItems) {
if (!Array.isArray(items)) {
return [];
}
const groupValues = collectGroupValues(menuItems);
return items.filter((item) => typeof item === 'string' && groupValues.has(item));
}
function getInitialOpenedItems(menuItems) {
try {
const stored = JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]');
return sanitizeOpenedItems(stored, menuItems);
} catch {
return [];
}
}
const sidebarMenu = shallowRef(applySidebarCustomization(sidebarItems));
const sidebarMenu = shallowRef(sidebarItems);
//
const openedItems = ref(getInitialOpenedItems(sidebarMenu.value));
watch(openedItems, (val) => {
localStorage.setItem('sidebar_openedItems', JSON.stringify(sanitizeOpenedItems(val, sidebarMenu.value)));
}, { deep: true });
function refreshSidebarMenu() {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
openedItems.value = sanitizeOpenedItems(openedItems.value, sidebarMenu.value);
}
const openedItems = ref(JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]'));
watch(openedItems, (val) => localStorage.setItem('sidebar_openedItems', JSON.stringify(val)), { deep: true });
// Apply customization on mount and listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'astrbot_sidebar_customization') {
refreshSidebarMenu();
sidebarMenu.value = applySidebarCustomization(sidebarItems);
}
};
const handleCustomEvent = () => {
refreshSidebarMenu();
sidebarMenu.value = applySidebarCustomization(sidebarItems);
};
onMounted(() => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
window.addEventListener('storage', handleStorageChange);
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
});
@@ -289,7 +255,7 @@ function openChangelogDialog() {
>
<div class="sidebar-container">
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<template v-for="(item, i) in sidebarMenu" :key="i">
<NavItem :item="item" class="leftPadding" />
</template>
</v-list>

Some files were not shown because too many files have changed in this diff Show More