Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 349ca05e26 | |||
| 7f3c0fdeb2 | |||
| 8e431e2076 | |||
| 89c11fd683 | |||
| 7cfe2aca99 | |||
| 3a938d2a13 | |||
| 812834bc9f | |||
| 51ff4f6e46 | |||
| 7ac169c5e8 | |||
| 61648ebe3e | |||
| 0610f0db0a | |||
| 8c935981bb | |||
| 3f3b4e4924 | |||
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde | |||
| 101580fd77 | |||
| 418f05f6e4 | |||
| df421e5554 | |||
| ed84074a60 | |||
| bbf61239ad | |||
| 92ee534a2c | |||
| fa4df0b5f3 | |||
| e5ac31efe7 | |||
| 2a7745c767 | |||
| 82e7502f74 | |||
| 866e546b59 | |||
| 6b642d7674 | |||
| 0711ec346f | |||
| 0dbe32e2dc | |||
| 4e855a17bc | |||
| f2fc724e0f | |||
| 460acf40c0 | |||
| cf29d9390f | |||
| ac44d1fdef |
@@ -36,7 +36,7 @@ jobs:
|
|||||||
zip -r dist.zip dist
|
zip -r dist.zip dist
|
||||||
|
|
||||||
- name: Archive production artifacts
|
- name: Archive production artifacts
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist-without-markdown
|
name: dist-without-markdown
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||||
|
|
||||||
- name: Upload dashboard artifact
|
- name: Upload dashboard artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Download dashboard artifact
|
- name: Download dashboard artifact
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||||
path: release-assets
|
path: release-assets
|
||||||
@@ -184,7 +184,8 @@ jobs:
|
|||||||
publish-pypi:
|
publish-pypi:
|
||||||
name: Publish PyPI
|
name: Publish PyPI
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: publish-release
|
needs:
|
||||||
|
- publish-release
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -192,6 +193,36 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
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
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
@@ -203,6 +234,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
shell: bash
|
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
|
run: uv build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ dashboard/dist/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
|
||||||
|
astrbot/dashboard/dist/
|
||||||
|
|
||||||
# Operating System
|
# Operating System
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
|||||||
|
|
||||||
### One-Click Deployment
|
### One-Click Deployment
|
||||||
|
|
||||||
For users who want to quickly experience AstrBot, we recommend using the one-click deployment method with `uv` ⚡️:
|
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,47 +83,58 @@ astrbot
|
|||||||
|
|
||||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
> 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
|
### Docker Deployment
|
||||||
|
|
||||||
For users who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
|
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||||
|
|
||||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
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
|
### Deploy on RainYun
|
||||||
|
|
||||||
For users who want to deploy AstrBot with one-click and don't want to manage the server, we recommend using RainYun's one-click cloud deployment service ☁️:
|
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### Desktop Application (Tauri)
|
### Desktop Application Deployment
|
||||||
|
|
||||||
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
|
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
|
||||||
|
|
||||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
Visit [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is designed for desktop usage and is not recommended for server scenarios.
|
||||||
|
|
||||||
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
|
### Launcher Deployment
|
||||||
|
|
||||||
### One-Click Launcher Deployment (AstrBot Launcher)
|
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
|
||||||
|
|
||||||
For users who want a quick deployment and multi-instance solution with environment isolation, we recommend using the AstrBot Launcher:
|
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||||
|
|
||||||
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
|
### Deploy on Replit
|
||||||
|
|
||||||
Community-contributed deployment method.
|
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### 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
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**More deployment methods**: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) | [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html)
|
**More deployment methods**
|
||||||
|
|
||||||
|
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||||
|
|
||||||
## Supported Messaging Platforms
|
## Supported Messaging Platforms
|
||||||
|
|
||||||
@@ -184,6 +195,13 @@ Connect AstrBot to your favorite chat platform.
|
|||||||
| Minimax TTS | Text-to-Speech Services |
|
| Minimax TTS | Text-to-Speech Services |
|
||||||
| Volcano Engine 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
|
## ❤️ Contributing
|
||||||
|
|
||||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||||
@@ -202,16 +220,20 @@ pip install pre-commit
|
|||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 🌍 Community
|
## 🌍 Community
|
||||||
|
|
||||||
### QQ Groups
|
### QQ Groups
|
||||||
|
|
||||||
|
- Group 9: 1076659624 (New)
|
||||||
|
- Group 10: 1078079676 (New)
|
||||||
- Group 1: 322154837
|
- Group 1: 322154837
|
||||||
- Group 3: 630166526
|
- Group 3: 630166526
|
||||||
- Group 5: 822130018
|
- Group 5: 822130018
|
||||||
- Group 6: 753075035
|
- Group 6: 753075035
|
||||||
- Group 7: 743746109
|
- Group 7: 743746109
|
||||||
- Group 8: 1030353265
|
- Group 8: 1030353265
|
||||||
|
|
||||||
- Developer Group: 975206796
|
- Developer Group: 975206796
|
||||||
|
|
||||||
### Discord Server
|
### Discord Server
|
||||||
|
|||||||
+27
-16
@@ -73,7 +73,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
|||||||
|
|
||||||
### Déploiement en un clic
|
### Déploiement en un clic
|
||||||
|
|
||||||
Pour les utilisateurs qui souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,47 +83,58 @@ astrbot
|
|||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
> [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
|
### Déploiement Docker
|
||||||
|
|
||||||
Pour les utilisateurs qui veulent un déploiement plus stable et prêt pour la production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
|
||||||
|
|
||||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||||
|
|
||||||
### Déployer sur RainYun
|
### Déployer sur RainYun
|
||||||
|
|
||||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### Application de bureau (Tauri)
|
### Déploiement de l'application de bureau
|
||||||
|
|
||||||
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
|
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
|
||||||
|
|
||||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
Accédez à [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer l'application ; cette méthode est conçue pour un usage desktop et n'est pas recommandée pour les scénarios serveur.
|
||||||
|
|
||||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. Solution de déploiement bureau en un clic, particulièrement adaptée aux débutants. Non recommandée pour les serveurs.
|
### Déploiement avec le lanceur
|
||||||
|
|
||||||
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
|
||||||
|
|
||||||
Pour les utilisateurs qui veulent une solution de déploiement rapide et multi-instances avec isolation d'environnement, nous recommandons d'utiliser AstrBot Launcher :
|
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||||
|
|
||||||
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
|
### Déployer sur Replit
|
||||||
|
|
||||||
Méthode de déploiement contribuée par la communauté.
|
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### 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
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**Autres méthodes de déploiement** : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html)
|
**Autres méthodes de déploiement**
|
||||||
|
|
||||||
|
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||||
|
|
||||||
## Plateformes de messagerie prises en charge
|
## Plateformes de messagerie prises en charge
|
||||||
|
|
||||||
|
|||||||
+26
-15
@@ -73,7 +73,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
|||||||
|
|
||||||
### ワンクリックデプロイ
|
### ワンクリックデプロイ
|
||||||
|
|
||||||
AstrBot を素早く試したいユーザーには、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
|
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,47 +83,58 @@ astrbot
|
|||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
|
||||||
|
|
||||||
|
`astrbot` の更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
### Docker デプロイ
|
### 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) をご参照ください。
|
||||||
|
|
||||||
### 雨云でのデプロイ
|
### 雨云でのデプロイ
|
||||||
|
|
||||||
サーバー管理をせずに AstrBot をワンクリックでデプロイしたいユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
AstrBot をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### デスクトップクライアント(Tauri)
|
### デスクトップアプリのデプロイ
|
||||||
|
|
||||||
デスクトップで AstrBot を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
|
デスクトップで AstrBot を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします。
|
||||||
|
|
||||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません。
|
||||||
|
|
||||||
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
|
### ランチャーのデプロイ
|
||||||
|
|
||||||
### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
|
||||||
|
|
||||||
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
|
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
|
||||||
|
|
||||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
|
|
||||||
|
|
||||||
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
|
|
||||||
|
|
||||||
### Replit でのデプロイ
|
### Replit でのデプロイ
|
||||||
|
|
||||||
コミュニティ貢献によるデプロイ方法。
|
Replit デプロイはコミュニティ提供の方式で、オンラインデモや軽量な試用に向いています。
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### AUR
|
||||||
|
|
||||||
|
AUR 方式は Arch Linux ユーザー向けで、システムのパッケージ運用に合わせて AstrBot を導入したい場合に適しています。
|
||||||
|
|
||||||
|
次のコマンドで `astrbot-git` をインストールし、ローカル環境で AstrBot を起動してください。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**その他のデプロイ方法**:[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) | [手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)
|
**その他のデプロイ方法**
|
||||||
|
|
||||||
|
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||||
|
|
||||||
## サポートされているメッセージプラットフォーム
|
## サポートされているメッセージプラットフォーム
|
||||||
|
|
||||||
|
|||||||
+27
-16
@@ -73,7 +73,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
|||||||
|
|
||||||
### Развёртывание в один клик
|
### Развёртывание в один клик
|
||||||
|
|
||||||
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,47 +83,58 @@ astrbot
|
|||||||
|
|
||||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||||
|
|
||||||
|
Обновить `astrbot`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
### Развёртывание Docker
|
### Развёртывание 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
|
### Развёртывание на RainYun
|
||||||
|
|
||||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не управлять сервером самостоятельно, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять сервером, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### Десктопное приложение (Tauri)
|
### Развёртывание десктопного приложения
|
||||||
|
|
||||||
Для пользователей, которые хотят использовать AstrBot на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
|
Для пользователей, которые хотят использовать AstrBot на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
|
||||||
|
|
||||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
|
||||||
|
|
||||||
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
|
### Развёртывание через лаунчер
|
||||||
|
|
||||||
### Установка в один клик через лаунчер (AstrBot Launcher)
|
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
|
||||||
|
|
||||||
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
|
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
|
||||||
|
|
||||||
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
|
|
||||||
|
|
||||||
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
|
|
||||||
|
|
||||||
### Развёртывание на Replit
|
### Развёртывание на Replit
|
||||||
|
|
||||||
Метод развёртывания от сообщества.
|
Развёртывание через Replit поддерживается сообществом и подходит для онлайн-демо и лёгких тестовых запусков.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### AUR
|
||||||
|
|
||||||
|
AUR-вариант предназначен для пользователей Arch Linux, которым удобна установка через системный менеджер пакетов.
|
||||||
|
|
||||||
|
Выполните команду ниже для установки `astrbot-git`, затем запустите AstrBot локально.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**Другие способы развёртывания**: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html)
|
**Другие способы развёртывания**
|
||||||
|
|
||||||
|
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||||
|
|
||||||
## Поддерживаемые платформы обмена сообщениями
|
## Поддерживаемые платформы обмена сообщениями
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -73,7 +73,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
|||||||
|
|
||||||
### 一鍵部署
|
### 一鍵部署
|
||||||
|
|
||||||
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️:
|
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,11 +83,20 @@ astrbot
|
|||||||
|
|
||||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
|
||||||
|
|
||||||
|
更新 `astrbot`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
對於希望獲得更穩定、更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||||
|
|
||||||
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
請參考官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||||
|
|
||||||
### 在雨雲上部署
|
### 在雨雲上部署
|
||||||
|
|
||||||
@@ -95,35 +104,37 @@ astrbot
|
|||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### 桌面客戶端(Tauri)
|
### 桌面客戶端部署
|
||||||
|
|
||||||
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App:
|
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App。
|
||||||
|
|
||||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景。
|
||||||
|
|
||||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
### 啟動器部署
|
||||||
|
|
||||||
### 啟動器一鍵部署(AstrBot Launcher)
|
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher。
|
||||||
|
|
||||||
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher:
|
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||||
|
|
||||||
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
|
||||||
|
|
||||||
一個快速部署和多開方案,實現環境隔離。
|
|
||||||
|
|
||||||
### 在 Replit 上部署
|
### 在 Replit 上部署
|
||||||
|
|
||||||
社群貢獻的部署方式。
|
Replit 部署由社群維護,適合線上示範與輕量試用情境。
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### AUR
|
||||||
|
|
||||||
|
AUR 方式面向 Arch Linux 使用者,適合希望透過系統套件管理器安裝 AstrBot 的場景。
|
||||||
|
|
||||||
|
在終端執行下方命令安裝 `astrbot-git` 套件,安裝完成後即可啟動使用。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**更多部署方式**:[寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手動部署](https://astrbot.app/deploy/astrbot/cli.html)
|
**更多部署方式**
|
||||||
|
|
||||||
|
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||||
|
|
||||||
## 支援的訊息平台
|
## 支援的訊息平台
|
||||||
|
|
||||||
@@ -206,10 +217,14 @@ pre-commit install
|
|||||||
|
|
||||||
### QQ 群組
|
### QQ 群組
|
||||||
|
|
||||||
|
- 9 群: 1076659624 (新)
|
||||||
|
- 10 群: 1078079676 (新)
|
||||||
- 1 群:322154837
|
- 1 群:322154837
|
||||||
- 3 群:630166526
|
- 3 群:630166526
|
||||||
- 5 群:822130018
|
- 5 群:822130018
|
||||||
- 6 群:753075035
|
- 6 群:753075035
|
||||||
|
- 7 群:743746109
|
||||||
|
- 8 群:1030353265
|
||||||
- 開發者群:975206796
|
- 開發者群:975206796
|
||||||
|
|
||||||
### Discord 群組
|
### Discord 群組
|
||||||
|
|||||||
+28
-15
@@ -73,7 +73,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
|||||||
|
|
||||||
### 一键部署
|
### 一键部署
|
||||||
|
|
||||||
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️:
|
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
@@ -83,11 +83,20 @@ astrbot
|
|||||||
|
|
||||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||||
|
|
||||||
|
更新 `astrbot`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade astrbot
|
||||||
|
```
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
对于希望获得更稳定、更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||||
|
|
||||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||||
|
|
||||||
### 在 雨云 上部署
|
### 在 雨云 上部署
|
||||||
|
|
||||||
@@ -95,35 +104,37 @@ astrbot
|
|||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
### 桌面客户端(Tauri)
|
### 桌面客户端部署
|
||||||
|
|
||||||
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App:
|
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||||
|
|
||||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||||
|
|
||||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
### 启动器部署
|
||||||
|
|
||||||
### 启动器一键部署(AstrBot Launcher)
|
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||||
|
|
||||||
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher:
|
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||||
|
|
||||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
|
||||||
|
|
||||||
一个快速部署和多开方案,实现环境隔离。
|
|
||||||
|
|
||||||
### 在 Replit 上部署
|
### 在 Replit 上部署
|
||||||
|
|
||||||
社区贡献的部署方式。
|
Replit 部署由社区维护,适合在线演示和轻量试用场景。
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
### AUR
|
### AUR
|
||||||
|
|
||||||
|
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||||
|
|
||||||
|
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S astrbot-git
|
yay -S astrbot-git
|
||||||
```
|
```
|
||||||
|
|
||||||
**更多部署方式**:[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
**更多部署方式**
|
||||||
|
|
||||||
|
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||||
|
|
||||||
## 支持的消息平台
|
## 支持的消息平台
|
||||||
|
|
||||||
@@ -207,6 +218,8 @@ pre-commit install
|
|||||||
|
|
||||||
### QQ 群组
|
### QQ 群组
|
||||||
|
|
||||||
|
- 9 群: 1076659624 (新)
|
||||||
|
- 10 群: 1078079676 (新)
|
||||||
- 1 群:322154837
|
- 1 群:322154837
|
||||||
- 3 群:630166526
|
- 3 群:630166526
|
||||||
- 5 群:822130018
|
- 5 群:822130018
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "4.18.3"
|
__version__ = "4.19.2"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""AstrBot CLI入口"""
|
"""AstrBot CLI entry point"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -29,23 +29,23 @@ def cli() -> None:
|
|||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("command_name", required=False, type=str)
|
@click.argument("command_name", required=False, type=str)
|
||||||
def help(command_name: str | None) -> None:
|
def help(command_name: str | None) -> None:
|
||||||
"""显示命令的帮助信息
|
"""Display help information for commands
|
||||||
|
|
||||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
If COMMAND_NAME is provided, display detailed help for that command.
|
||||||
否则,显示通用帮助信息。
|
Otherwise, display general help information.
|
||||||
"""
|
"""
|
||||||
ctx = click.get_current_context()
|
ctx = click.get_current_context()
|
||||||
if command_name:
|
if command_name:
|
||||||
# 查找指定命令
|
# Find the specified command
|
||||||
command = cli.get_command(ctx, command_name)
|
command = cli.get_command(ctx, command_name)
|
||||||
if command:
|
if command:
|
||||||
# 显示特定命令的帮助信息
|
# Display help for the specific command
|
||||||
click.echo(command.get_help(ctx))
|
click.echo(command.get_help(ctx))
|
||||||
else:
|
else:
|
||||||
click.echo(f"Unknown command: {command_name}")
|
click.echo(f"Unknown command: {command_name}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
# 显示通用帮助信息
|
# Display general help information
|
||||||
click.echo(cli.get_help(ctx))
|
click.echo(cli.get_help(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,57 +10,61 @@ from ..utils import check_astrbot_root, get_astrbot_root
|
|||||||
|
|
||||||
|
|
||||||
def _validate_log_level(value: str) -> str:
|
def _validate_log_level(value: str) -> str:
|
||||||
"""验证日志级别"""
|
"""Validate log level"""
|
||||||
value = value.upper()
|
value = value.upper()
|
||||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_port(value: str) -> int:
|
def _validate_dashboard_port(value: str) -> int:
|
||||||
"""验证 Dashboard 端口"""
|
"""Validate Dashboard port"""
|
||||||
try:
|
try:
|
||||||
port = int(value)
|
port = int(value)
|
||||||
if port < 1 or port > 65535:
|
if port < 1 or port > 65535:
|
||||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
raise click.ClickException("Port must be in range 1-65535")
|
||||||
return port
|
return port
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise click.ClickException("端口必须是数字")
|
raise click.ClickException("Port must be a number")
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_username(value: str) -> str:
|
def _validate_dashboard_username(value: str) -> str:
|
||||||
"""验证 Dashboard 用户名"""
|
"""Validate Dashboard username"""
|
||||||
if not value:
|
if not value:
|
||||||
raise click.ClickException("用户名不能为空")
|
raise click.ClickException("Username cannot be empty")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_dashboard_password(value: str) -> str:
|
def _validate_dashboard_password(value: str) -> str:
|
||||||
"""验证 Dashboard 密码"""
|
"""Validate Dashboard password"""
|
||||||
if not value:
|
if not value:
|
||||||
raise click.ClickException("密码不能为空")
|
raise click.ClickException("Password cannot be empty")
|
||||||
return hashlib.md5(value.encode()).hexdigest()
|
return hashlib.md5(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _validate_timezone(value: str) -> str:
|
def _validate_timezone(value: str) -> str:
|
||||||
"""验证时区"""
|
"""Validate timezone"""
|
||||||
try:
|
try:
|
||||||
zoneinfo.ZoneInfo(value)
|
zoneinfo.ZoneInfo(value)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
raise click.ClickException(
|
||||||
|
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _validate_callback_api_base(value: str) -> str:
|
def _validate_callback_api_base(value: str) -> str:
|
||||||
"""验证回调接口基址"""
|
"""Validate callback API base URL"""
|
||||||
if not value.startswith("http://") and not value.startswith("https://"):
|
if not value.startswith("http://") and not value.startswith("https://"):
|
||||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
raise click.ClickException(
|
||||||
|
"Callback API base must start with http:// or https://"
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||||
"timezone": _validate_timezone,
|
"timezone": _validate_timezone,
|
||||||
"log_level": _validate_log_level,
|
"log_level": _validate_log_level,
|
||||||
@@ -72,11 +76,11 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
|||||||
|
|
||||||
|
|
||||||
def _load_config() -> dict[str, Any]:
|
def _load_config() -> dict[str, Any]:
|
||||||
"""加载或初始化配置文件"""
|
"""Load or initialize config file"""
|
||||||
root = get_astrbot_root()
|
root = get_astrbot_root()
|
||||||
if not check_astrbot_root(root):
|
if not check_astrbot_root(root):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
|
|
||||||
config_path = root / "data" / "cmd_config.json"
|
config_path = root / "data" / "cmd_config.json"
|
||||||
@@ -91,11 +95,11 @@ def _load_config() -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||||
|
|
||||||
|
|
||||||
def _save_config(config: dict[str, Any]) -> None:
|
def _save_config(config: dict[str, Any]) -> None:
|
||||||
"""保存配置文件"""
|
"""Save config file"""
|
||||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||||
|
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -105,21 +109,21 @@ def _save_config(config: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _set_nested_item(obj: dict[str, Any], path: str, value: 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(".")
|
parts = path.split(".")
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
if part not in obj:
|
if part not in obj:
|
||||||
obj[part] = {}
|
obj[part] = {}
|
||||||
elif not isinstance(obj[part], dict):
|
elif not isinstance(obj[part], dict):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||||
)
|
)
|
||||||
obj = obj[part]
|
obj = obj[part]
|
||||||
obj[parts[-1]] = value
|
obj[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||||
"""获取嵌套字典中的值"""
|
"""Get a value from a nested dictionary"""
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
for part in parts:
|
for part in parts:
|
||||||
obj = obj[part]
|
obj = obj[part]
|
||||||
@@ -128,21 +132,21 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
|||||||
|
|
||||||
@click.group(name="conf")
|
@click.group(name="conf")
|
||||||
def conf() -> None:
|
def conf() -> None:
|
||||||
"""配置管理命令
|
"""Configuration management commands
|
||||||
|
|
||||||
支持的配置项:
|
Supported config keys:
|
||||||
|
|
||||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||||
|
|
||||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||||
|
|
||||||
- dashboard.port: Dashboard 端口
|
- dashboard.port: Dashboard port
|
||||||
|
|
||||||
- dashboard.username: Dashboard 用户名
|
- dashboard.username: Dashboard username
|
||||||
|
|
||||||
- dashboard.password: Dashboard 密码
|
- dashboard.password: Dashboard password
|
||||||
|
|
||||||
- callback_api_base: 回调接口基址
|
- callback_api_base: Callback API base URL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -150,9 +154,9 @@ def conf() -> None:
|
|||||||
@click.argument("key")
|
@click.argument("key")
|
||||||
@click.argument("value")
|
@click.argument("value")
|
||||||
def set_config(key: str, value: str) -> None:
|
def set_config(key: str, value: str) -> None:
|
||||||
"""设置配置项的值"""
|
"""Set the value of a config item"""
|
||||||
if key not in CONFIG_VALIDATORS:
|
if key not in CONFIG_VALIDATORS:
|
||||||
raise click.ClickException(f"不支持的配置项: {key}")
|
raise click.ClickException(f"Unsupported config key: {key}")
|
||||||
|
|
||||||
config = _load_config()
|
config = _load_config()
|
||||||
|
|
||||||
@@ -162,29 +166,29 @@ def set_config(key: str, value: str) -> None:
|
|||||||
_set_nested_item(config, key, validated_value)
|
_set_nested_item(config, key, validated_value)
|
||||||
_save_config(config)
|
_save_config(config)
|
||||||
|
|
||||||
click.echo(f"配置已更新: {key}")
|
click.echo(f"Config updated: {key}")
|
||||||
if key == "dashboard.password":
|
if key == "dashboard.password":
|
||||||
click.echo(" 原值: ********")
|
click.echo(" Old value: ********")
|
||||||
click.echo(" 新值: ********")
|
click.echo(" New value: ********")
|
||||||
else:
|
else:
|
||||||
click.echo(f" 原值: {old_value}")
|
click.echo(f" Old value: {old_value}")
|
||||||
click.echo(f" 新值: {validated_value}")
|
click.echo(f" New value: {validated_value}")
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise click.ClickException(f"未知的配置项: {key}")
|
raise click.ClickException(f"Unknown config key: {key}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||||
|
|
||||||
|
|
||||||
@conf.command(name="get")
|
@conf.command(name="get")
|
||||||
@click.argument("key", required=False)
|
@click.argument("key", required=False)
|
||||||
def get_config(key: str | None = None) -> None:
|
def get_config(key: str | None = None) -> None:
|
||||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||||
config = _load_config()
|
config = _load_config()
|
||||||
|
|
||||||
if key:
|
if key:
|
||||||
if key not in CONFIG_VALIDATORS:
|
if key not in CONFIG_VALIDATORS:
|
||||||
raise click.ClickException(f"不支持的配置项: {key}")
|
raise click.ClickException(f"Unsupported config key: {key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
value = _get_nested_item(config, key)
|
value = _get_nested_item(config, key)
|
||||||
@@ -192,11 +196,11 @@ def get_config(key: str | None = None) -> None:
|
|||||||
value = "********"
|
value = "********"
|
||||||
click.echo(f"{key}: {value}")
|
click.echo(f"{key}: {value}")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise click.ClickException(f"未知的配置项: {key}")
|
raise click.ClickException(f"Unknown config key: {key}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||||
else:
|
else:
|
||||||
click.echo("当前配置:")
|
click.echo("Current config:")
|
||||||
for key in CONFIG_VALIDATORS:
|
for key in CONFIG_VALIDATORS:
|
||||||
try:
|
try:
|
||||||
value = (
|
value = (
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ from ..utils import check_dashboard, get_astrbot_root
|
|||||||
|
|
||||||
|
|
||||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||||
"""执行 AstrBot 初始化逻辑"""
|
"""Execute AstrBot initialization logic"""
|
||||||
dot_astrbot = astrbot_root / ".astrbot"
|
dot_astrbot = astrbot_root / ".astrbot"
|
||||||
|
|
||||||
if not dot_astrbot.exists():
|
if not dot_astrbot.exists():
|
||||||
click.echo(f"Current Directory: {astrbot_root}")
|
|
||||||
click.echo(
|
|
||||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
|
||||||
)
|
|
||||||
if click.confirm(
|
if click.confirm(
|
||||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
f"Install AstrBot to this directory? {astrbot_root}",
|
||||||
default=True,
|
default=True,
|
||||||
abort=True,
|
abort=True,
|
||||||
):
|
):
|
||||||
@@ -40,7 +36,7 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
|||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
def init() -> None:
|
def init() -> None:
|
||||||
"""初始化 AstrBot"""
|
"""Initialize AstrBot"""
|
||||||
click.echo("Initializing AstrBot...")
|
click.echo("Initializing AstrBot...")
|
||||||
astrbot_root = get_astrbot_root()
|
astrbot_root = get_astrbot_root()
|
||||||
lock_file = astrbot_root / "astrbot.lock"
|
lock_file = astrbot_root / "astrbot.lock"
|
||||||
@@ -49,8 +45,11 @@ def init() -> None:
|
|||||||
try:
|
try:
|
||||||
with lock.acquire():
|
with lock.acquire():
|
||||||
asyncio.run(initialize_astrbot(astrbot_root))
|
asyncio.run(initialize_astrbot(astrbot_root))
|
||||||
|
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||||
except Timeout:
|
except Timeout:
|
||||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
raise click.ClickException(
|
||||||
|
"Cannot acquire lock file. Please check if another instance is running"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"初始化失败: {e!s}")
|
raise click.ClickException(f"Initialization failed: {e!s}")
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ from ..utils import (
|
|||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
def plug() -> None:
|
def plug() -> None:
|
||||||
"""插件管理"""
|
"""Plugin management"""
|
||||||
|
|
||||||
|
|
||||||
def _get_data_path() -> Path:
|
def _get_data_path() -> Path:
|
||||||
base = get_astrbot_root()
|
base = get_astrbot_root()
|
||||||
if not check_astrbot_root(base):
|
if not check_astrbot_root(base):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
return (base / "data").resolve()
|
return (base / "data").resolve()
|
||||||
|
|
||||||
@@ -32,7 +32,9 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
|||||||
if title:
|
if title:
|
||||||
click.echo(click.style(title, fg=color, bold=True))
|
click.echo(click.style(title, fg=color, bold=True))
|
||||||
|
|
||||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
click.echo(
|
||||||
|
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||||
|
)
|
||||||
click.echo("-" * 85)
|
click.echo("-" * 85)
|
||||||
|
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
@@ -46,30 +48,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
|||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def new(name: str) -> None:
|
def new(name: str) -> None:
|
||||||
"""创建新插件"""
|
"""Create a new plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins" / name
|
plug_path = base_path / "plugins" / name
|
||||||
|
|
||||||
if plug_path.exists():
|
if plug_path.exists():
|
||||||
raise click.ClickException(f"插件 {name} 已存在")
|
raise click.ClickException(f"Plugin {name} already exists")
|
||||||
|
|
||||||
author = click.prompt("请输入插件作者", type=str)
|
author = click.prompt("Enter plugin author", type=str)
|
||||||
desc = click.prompt("请输入插件描述", type=str)
|
desc = click.prompt("Enter plugin description", type=str)
|
||||||
version = click.prompt("请输入插件版本", type=str)
|
version = click.prompt("Enter plugin version", type=str)
|
||||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||||
repo = click.prompt("请输入插件仓库:", type=str)
|
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||||
if not repo.startswith("http"):
|
if not repo.startswith("http"):
|
||||||
raise click.ClickException("仓库地址必须以 http 开头")
|
raise click.ClickException("Repository URL must start with http")
|
||||||
|
|
||||||
click.echo("下载插件模板...")
|
click.echo("Downloading plugin template...")
|
||||||
get_git_repo(
|
get_git_repo(
|
||||||
"https://github.com/Soulter/helloworld",
|
"https://github.com/Soulter/helloworld",
|
||||||
plug_path,
|
plug_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
click.echo("重写插件信息...")
|
click.echo("Rewriting plugin metadata...")
|
||||||
# 重写 metadata.yaml
|
# Rewrite metadata.yaml
|
||||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||||
f.write(
|
f.write(
|
||||||
f"name: {name}\n"
|
f"name: {name}\n"
|
||||||
@@ -79,11 +81,13 @@ def new(name: str) -> None:
|
|||||||
f"repo: {repo}\n",
|
f"repo: {repo}\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 重写 README.md
|
# Rewrite README.md
|
||||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
f.write(
|
||||||
|
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||||
|
)
|
||||||
|
|
||||||
# 重写 main.py
|
# Rewrite main.py
|
||||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
@@ -95,54 +99,54 @@ def new(name: str) -> None:
|
|||||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
|
|
||||||
click.echo(f"插件 {name} 创建成功")
|
click.echo(f"Plugin {name} created successfully")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||||
def list(all: bool) -> None:
|
def list(all: bool) -> None:
|
||||||
"""列出插件"""
|
"""List plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
|
|
||||||
# 未发布的插件
|
# Unpublished plugins
|
||||||
not_published_plugins = [
|
not_published_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
||||||
]
|
]
|
||||||
if not_published_plugins:
|
if not_published_plugins:
|
||||||
display_plugins(not_published_plugins, "未发布的插件", "red")
|
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
|
||||||
|
|
||||||
# 需要更新的插件
|
# Plugins needing update
|
||||||
need_update_plugins = [
|
need_update_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
||||||
]
|
]
|
||||||
if need_update_plugins:
|
if need_update_plugins:
|
||||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
|
||||||
|
|
||||||
# 已安装的插件
|
# Installed plugins
|
||||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||||
if installed_plugins:
|
if installed_plugins:
|
||||||
display_plugins(installed_plugins, "已安装的插件", "green")
|
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||||
|
|
||||||
# 未安装的插件
|
# Uninstalled plugins
|
||||||
not_installed_plugins = [
|
not_installed_plugins = [
|
||||||
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
||||||
]
|
]
|
||||||
if not_installed_plugins and all:
|
if not_installed_plugins and all:
|
||||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||||
and not all
|
and not all
|
||||||
):
|
):
|
||||||
click.echo("未安装任何插件")
|
click.echo("No plugins installed")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
@click.option("--proxy", help="代理服务器地址")
|
@click.option("--proxy", help="Proxy server address")
|
||||||
def install(name: str, proxy: str | None) -> None:
|
def install(name: str, proxy: str | None) -> None:
|
||||||
"""安装插件"""
|
"""Install a plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins"
|
plug_path = base_path / "plugins"
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
@@ -157,7 +161,7 @@ def install(name: str, proxy: str | None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
raise click.ClickException(f"Plugin {name} not found or already installed")
|
||||||
|
|
||||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||||
|
|
||||||
@@ -165,30 +169,32 @@ def install(name: str, proxy: str | None) -> None:
|
|||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def remove(name: str) -> None:
|
def remove(name: str) -> None:
|
||||||
"""卸载插件"""
|
"""Uninstall a plugin"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
plugin = next((p for p in plugins if p["name"] == name), None)
|
plugin = next((p for p in plugins if p["name"] == name), None)
|
||||||
|
|
||||||
if not plugin or not plugin.get("local_path"):
|
if not plugin or not plugin.get("local_path"):
|
||||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
|
||||||
|
|
||||||
plugin_path = plugin["local_path"]
|
plugin_path = plugin["local_path"]
|
||||||
|
|
||||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
click.confirm(
|
||||||
|
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(plugin_path)
|
shutil.rmtree(plugin_path)
|
||||||
click.echo(f"插件 {name} 已卸载")
|
click.echo(f"Plugin {name} has been uninstalled")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("name", required=False)
|
@click.argument("name", required=False)
|
||||||
@click.option("--proxy", help="Github代理地址")
|
@click.option("--proxy", help="GitHub proxy address")
|
||||||
def update(name: str, proxy: str | None) -> None:
|
def update(name: str, proxy: str | None) -> None:
|
||||||
"""更新插件"""
|
"""Update plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plug_path = base_path / "plugins"
|
plug_path = base_path / "plugins"
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
@@ -204,7 +210,9 @@ def update(name: str, proxy: str | None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
raise click.ClickException(
|
||||||
|
f"Plugin {name} does not need updating or cannot be updated"
|
||||||
|
)
|
||||||
|
|
||||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||||
else:
|
else:
|
||||||
@@ -213,20 +221,20 @@ def update(name: str, proxy: str | None) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not need_update_plugins:
|
if not need_update_plugins:
|
||||||
click.echo("没有需要更新的插件")
|
click.echo("No plugins need updating")
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||||
for plugin in need_update_plugins:
|
for plugin in need_update_plugins:
|
||||||
plugin_name = plugin["name"]
|
plugin_name = plugin["name"]
|
||||||
click.echo(f"正在更新插件 {plugin_name}...")
|
click.echo(f"Updating plugin {plugin_name}...")
|
||||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||||
|
|
||||||
|
|
||||||
@plug.command()
|
@plug.command()
|
||||||
@click.argument("query")
|
@click.argument("query")
|
||||||
def search(query: str) -> None:
|
def search(query: str) -> None:
|
||||||
"""搜索插件"""
|
"""Search for plugins"""
|
||||||
base_path = _get_data_path()
|
base_path = _get_data_path()
|
||||||
plugins = build_plug_list(base_path / "plugins")
|
plugins = build_plug_list(base_path / "plugins")
|
||||||
|
|
||||||
@@ -239,7 +247,7 @@ def search(query: str) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not matched_plugins:
|
if not matched_plugins:
|
||||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
click.echo(f"No plugins matching '{query}' found")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
|||||||
|
|
||||||
|
|
||||||
async def run_astrbot(astrbot_root: Path) -> None:
|
async def run_astrbot(astrbot_root: Path) -> None:
|
||||||
"""运行 AstrBot"""
|
"""Run AstrBot"""
|
||||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||||
from astrbot.core.initial_loader import InitialLoader
|
from astrbot.core.initial_loader import InitialLoader
|
||||||
|
|
||||||
@@ -26,18 +26,18 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
|||||||
await core_lifecycle.start()
|
await core_lifecycle.start()
|
||||||
|
|
||||||
|
|
||||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||||
@click.command()
|
@click.command()
|
||||||
def run(reload: bool, port: str) -> None:
|
def run(reload: bool, port: str) -> None:
|
||||||
"""运行 AstrBot"""
|
"""Run AstrBot"""
|
||||||
try:
|
try:
|
||||||
os.environ["ASTRBOT_CLI"] = "1"
|
os.environ["ASTRBOT_CLI"] = "1"
|
||||||
astrbot_root = get_astrbot_root()
|
astrbot_root = get_astrbot_root()
|
||||||
|
|
||||||
if not check_astrbot_root(astrbot_root):
|
if not check_astrbot_root(astrbot_root):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||||
)
|
)
|
||||||
|
|
||||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||||
@@ -47,7 +47,7 @@ def run(reload: bool, port: str) -> None:
|
|||||||
os.environ["DASHBOARD_PORT"] = port
|
os.environ["DASHBOARD_PORT"] = port
|
||||||
|
|
||||||
if reload:
|
if reload:
|
||||||
click.echo("启用插件自动重载")
|
click.echo("Plugin auto-reload enabled")
|
||||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||||
|
|
||||||
lock_file = astrbot_root / "astrbot.lock"
|
lock_file = astrbot_root / "astrbot.lock"
|
||||||
@@ -55,8 +55,10 @@ def run(reload: bool, port: str) -> None:
|
|||||||
with lock.acquire():
|
with lock.acquire():
|
||||||
asyncio.run(run_astrbot(astrbot_root))
|
asyncio.run(run_astrbot(astrbot_root))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
click.echo("AstrBot 已关闭...")
|
click.echo("AstrBot has been shut down.")
|
||||||
except Timeout:
|
except Timeout:
|
||||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
raise click.ClickException(
|
||||||
|
"Cannot acquire lock file. Please check if another instance is running"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
|
||||||
|
|||||||
+21
-13
@@ -2,9 +2,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
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:
|
def check_astrbot_root(path: str | Path) -> bool:
|
||||||
"""检查路径是否为 AstrBot 根目录"""
|
"""Check if the path is an AstrBot root directory"""
|
||||||
if not isinstance(path, Path):
|
if not isinstance(path, Path):
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.exists() or not path.is_dir():
|
if not path.exists() or not path.is_dir():
|
||||||
@@ -15,43 +18,48 @@ def check_astrbot_root(path: str | Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_astrbot_root() -> Path:
|
def get_astrbot_root() -> Path:
|
||||||
"""获取Astrbot根目录路径"""
|
"""Get the AstrBot root directory path"""
|
||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
async def check_dashboard(astrbot_root: Path) -> None:
|
async def check_dashboard(astrbot_root: Path) -> None:
|
||||||
"""检查是否安装了dashboard"""
|
"""Check if the dashboard is installed"""
|
||||||
from astrbot.core.config.default import VERSION
|
from astrbot.core.config.default import VERSION
|
||||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||||
|
|
||||||
from .version_comparator import VersionComparator
|
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:
|
try:
|
||||||
dashboard_version = await get_dashboard_version()
|
dashboard_version = await get_dashboard_version()
|
||||||
match dashboard_version:
|
match dashboard_version:
|
||||||
case None:
|
case None:
|
||||||
click.echo("未安装管理面板")
|
click.echo("Dashboard is not installed")
|
||||||
if click.confirm(
|
if click.confirm(
|
||||||
"是否安装管理面板?",
|
"Install dashboard?",
|
||||||
default=True,
|
default=True,
|
||||||
abort=True,
|
abort=True,
|
||||||
):
|
):
|
||||||
click.echo("正在安装管理面板...")
|
click.echo("Installing dashboard...")
|
||||||
await download_dashboard(
|
await download_dashboard(
|
||||||
path="data/dashboard.zip",
|
path="data/dashboard.zip",
|
||||||
extract_path=str(astrbot_root),
|
extract_path=str(astrbot_root),
|
||||||
version=f"v{VERSION}",
|
version=f"v{VERSION}",
|
||||||
latest=False,
|
latest=False,
|
||||||
)
|
)
|
||||||
click.echo("管理面板安装完成")
|
click.echo("Dashboard installed successfully")
|
||||||
|
|
||||||
case str():
|
case str():
|
||||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||||
click.echo("管理面板已是最新版本")
|
click.echo("Dashboard is already up to date")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
version = dashboard_version.split("v")[1]
|
version = dashboard_version.split("v")[1]
|
||||||
click.echo(f"管理面板版本: {version}")
|
click.echo(f"Dashboard version: {version}")
|
||||||
await download_dashboard(
|
await download_dashboard(
|
||||||
path="data/dashboard.zip",
|
path="data/dashboard.zip",
|
||||||
extract_path=str(astrbot_root),
|
extract_path=str(astrbot_root),
|
||||||
@@ -59,10 +67,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
|||||||
latest=False,
|
latest=False,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"下载管理面板失败: {e}")
|
click.echo(f"Failed to download dashboard: {e}")
|
||||||
return
|
return
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
click.echo("初始化管理面板目录...")
|
click.echo("Initializing dashboard directory...")
|
||||||
try:
|
try:
|
||||||
await download_dashboard(
|
await download_dashboard(
|
||||||
path=str(astrbot_root / "dashboard.zip"),
|
path=str(astrbot_root / "dashboard.zip"),
|
||||||
@@ -70,7 +78,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
|||||||
version=f"v{VERSION}",
|
version=f"v{VERSION}",
|
||||||
latest=False,
|
latest=False,
|
||||||
)
|
)
|
||||||
click.echo("管理面板初始化完成")
|
click.echo("Dashboard initialized successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"下载管理面板失败: {e}")
|
click.echo(f"Failed to download dashboard: {e}")
|
||||||
return
|
return
|
||||||
|
|||||||
+47
-43
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
|
|||||||
|
|
||||||
|
|
||||||
class PluginStatus(str, Enum):
|
class PluginStatus(str, Enum):
|
||||||
INSTALLED = "已安装"
|
INSTALLED = "installed"
|
||||||
NEED_UPDATE = "需更新"
|
NEED_UPDATE = "needs-update"
|
||||||
NOT_INSTALLED = "未安装"
|
NOT_INSTALLED = "not-installed"
|
||||||
NOT_PUBLISHED = "未发布"
|
NOT_PUBLISHED = "unpublished"
|
||||||
|
|
||||||
|
|
||||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
"""Download code from a Git repository and extract to the specified path"""
|
||||||
temp_dir = Path(tempfile.mkdtemp())
|
temp_dir = Path(tempfile.mkdtemp())
|
||||||
try:
|
try:
|
||||||
# 解析仓库信息
|
# Parse repository info
|
||||||
repo_namespace = url.split("/")[-2:]
|
repo_namespace = url.split("/")[-2:]
|
||||||
author = repo_namespace[0]
|
author = repo_namespace[0]
|
||||||
repo = repo_namespace[1]
|
repo = repo_namespace[1]
|
||||||
|
|
||||||
# 尝试获取最新的 release
|
# Try to get the latest release
|
||||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||||
try:
|
try:
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
|||||||
releases = resp.json()
|
releases = resp.json()
|
||||||
|
|
||||||
if releases:
|
if releases:
|
||||||
# 使用最新的 release
|
# Use the latest release
|
||||||
download_url = releases[0]["zipball_url"]
|
download_url = releases[0]["zipball_url"]
|
||||||
else:
|
else:
|
||||||
# 没有 release,使用默认分支
|
# No release found, use default branch
|
||||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||||
download_url = url
|
download_url = url
|
||||||
|
|
||||||
# 应用代理
|
# Apply proxy
|
||||||
if proxy:
|
if proxy:
|
||||||
download_url = f"{proxy}/{download_url}"
|
download_url = f"{proxy}/{download_url}"
|
||||||
|
|
||||||
# 下载并解压
|
# Download and extract
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
proxy=proxy if proxy else None,
|
proxy=proxy if proxy else None,
|
||||||
follow_redirects=True,
|
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
|
and "archive/refs/heads/master.zip" in download_url
|
||||||
):
|
):
|
||||||
alt_url = download_url.replace("master.zip", "main.zip")
|
alt_url = download_url.replace("master.zip", "main.zip")
|
||||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
click.echo("Branch 'master' not found, trying 'main' branch")
|
||||||
resp = client.get(alt_url)
|
resp = client.get(alt_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
else:
|
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:
|
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||||
"""从 metadata.yaml 文件加载插件元数据
|
"""Load plugin metadata from metadata.yaml file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_dir: 插件目录路径
|
plugin_dir: Plugin directory path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||||
|
|
||||||
"""
|
"""
|
||||||
yaml_path = plugin_dir / "metadata.yaml"
|
yaml_path = plugin_dir / "metadata.yaml"
|
||||||
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
|||||||
try:
|
try:
|
||||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
|
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def build_plug_list(plugins_dir: Path) -> list:
|
def build_plug_list(plugins_dir: Path) -> list:
|
||||||
"""构建插件列表,包含本地和在线插件信息
|
"""Build plugin list containing local and online plugin information
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugins_dir (Path): 插件目录路径
|
plugins_dir (Path): Plugin directory path
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: 包含插件信息的字典列表
|
list: List of dicts containing plugin information
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# 获取本地插件信息
|
# Get local plugin info
|
||||||
result = []
|
result = []
|
||||||
if plugins_dir.exists():
|
if plugins_dir.exists():
|
||||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||||
plugin_dir = plugins_dir / plugin_name
|
plugin_dir = plugins_dir / plugin_name
|
||||||
|
|
||||||
# 从 metadata.yaml 加载元数据
|
# Load metadata from metadata.yaml
|
||||||
metadata = load_yaml_metadata(plugin_dir)
|
metadata = load_yaml_metadata(plugin_dir)
|
||||||
|
|
||||||
if "desc" not in metadata and "description" in metadata:
|
if "desc" not in metadata and "description" in metadata:
|
||||||
metadata["desc"] = metadata["description"]
|
metadata["desc"] = metadata["description"]
|
||||||
|
|
||||||
# 如果成功加载元数据,添加到结果列表
|
# If metadata loaded successfully, add to result list
|
||||||
if metadata and all(
|
if metadata and all(
|
||||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
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 = []
|
online_plugins = []
|
||||||
try:
|
try:
|
||||||
with httpx.Client() as client:
|
with httpx.Client() as client:
|
||||||
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||||
|
|
||||||
# 与在线插件比对,更新状态
|
# Compare with online plugins and update status
|
||||||
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
||||||
for local_plugin in result:
|
for local_plugin in result:
|
||||||
if local_plugin["name"] in online_plugin_names:
|
if local_plugin["name"] in online_plugin_names:
|
||||||
# 查找对应的在线插件
|
# Find the corresponding online plugin
|
||||||
online_plugin = next(
|
online_plugin = next(
|
||||||
p for p in online_plugins if p["name"] == local_plugin["name"]
|
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
|
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
||||||
else:
|
else:
|
||||||
# 本地插件未在线上发布
|
# Local plugin is not published online
|
||||||
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
||||||
|
|
||||||
# 添加未安装的在线插件
|
# Add uninstalled online plugins
|
||||||
for online_plugin in online_plugins:
|
for online_plugin in online_plugins:
|
||||||
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
||||||
result.append(online_plugin)
|
result.append(online_plugin)
|
||||||
@@ -196,19 +196,19 @@ def manage_plugin(
|
|||||||
is_update: bool = False,
|
is_update: bool = False,
|
||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""安装或更新插件
|
"""Install or update a plugin
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin (dict): 插件信息字典
|
plugin (dict): Plugin info dict
|
||||||
plugins_dir (Path): 插件目录
|
plugins_dir (Path): Plugins directory
|
||||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
||||||
proxy (str, optional): 代理服务器地址
|
proxy (str, optional): Proxy server address
|
||||||
|
|
||||||
"""
|
"""
|
||||||
plugin_name = plugin["name"]
|
plugin_name = plugin["name"]
|
||||||
repo_url = plugin["repo"]
|
repo_url = plugin["repo"]
|
||||||
|
|
||||||
# 如果是更新且有本地路径,直接使用本地路径
|
# If updating and local path exists, use it directly
|
||||||
if is_update and plugin.get("local_path"):
|
if is_update and plugin.get("local_path"):
|
||||||
target_path = Path(plugin["local_path"])
|
target_path = Path(plugin["local_path"])
|
||||||
else:
|
else:
|
||||||
@@ -216,11 +216,13 @@ def manage_plugin(
|
|||||||
|
|
||||||
backup_path = Path(f"{target_path}_backup") if is_update else None
|
backup_path = Path(f"{target_path}_backup") if is_update else None
|
||||||
|
|
||||||
# 检查插件是否存在
|
# Check if plugin exists
|
||||||
if is_update and not target_path.exists():
|
if is_update and not target_path.exists():
|
||||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
raise click.ClickException(
|
||||||
|
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||||
|
)
|
||||||
|
|
||||||
# 备份现有插件
|
# Backup existing plugin
|
||||||
if is_update and backup_path is not None and backup_path.exists():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.rmtree(backup_path)
|
shutil.rmtree(backup_path)
|
||||||
if is_update and backup_path is not None:
|
if is_update and backup_path is not None:
|
||||||
@@ -228,19 +230,21 @@ def manage_plugin(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(
|
click.echo(
|
||||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||||
)
|
)
|
||||||
get_git_repo(repo_url, target_path, proxy)
|
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():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.rmtree(backup_path)
|
shutil.rmtree(backup_path)
|
||||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
click.echo(
|
||||||
|
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
shutil.rmtree(target_path, ignore_errors=True)
|
shutil.rmtree(target_path, ignore_errors=True)
|
||||||
if is_update and backup_path is not None and backup_path.exists():
|
if is_update and backup_path is not None and backup_path.exists():
|
||||||
shutil.move(backup_path, target_path)
|
shutil.move(backup_path, target_path)
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
"""Copied from astrbot.core.utils.version_comparator"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -6,11 +6,11 @@ import re
|
|||||||
class VersionComparator:
|
class VersionComparator:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compare_version(v1: str, v2: str) -> int:
|
def compare_version(v1: str, v2: str) -> int:
|
||||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||||
|
|
||||||
参考: https://semver.org/lang/zh-CN/
|
Reference: https://semver.org/
|
||||||
|
|
||||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||||
"""
|
"""
|
||||||
v1 = v1.lower().replace("v", "")
|
v1 = v1.lower().replace("v", "")
|
||||||
v2 = v2.lower().replace("v", "")
|
v2 = v2.lower().replace("v", "")
|
||||||
@@ -24,7 +24,7 @@ class VersionComparator:
|
|||||||
return [], None
|
return [], None
|
||||||
major_minor_patch = match.group(1).split(".")
|
major_minor_patch = match.group(1).split(".")
|
||||||
prerelease = match.group(2)
|
prerelease = match.group(2)
|
||||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
|
||||||
parts = [int(x) for x in major_minor_patch]
|
parts = [int(x) for x in major_minor_patch]
|
||||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||||
return parts, prerelease
|
return parts, prerelease
|
||||||
@@ -32,7 +32,7 @@ class VersionComparator:
|
|||||||
v1_parts, v1_prerelease = split_version(v1)
|
v1_parts, v1_prerelease = split_version(v1)
|
||||||
v2_parts, v2_prerelease = split_version(v2)
|
v2_parts, v2_prerelease = split_version(v2)
|
||||||
|
|
||||||
# 比较数字部分
|
# Compare numeric parts
|
||||||
length = max(len(v1_parts), len(v2_parts))
|
length = max(len(v1_parts), len(v2_parts))
|
||||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||||
@@ -43,11 +43,11 @@ class VersionComparator:
|
|||||||
if v1_parts[i] < v2_parts[i]:
|
if v1_parts[i] < v2_parts[i]:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
# 比较预发布标签
|
# Compare pre-release tags
|
||||||
if v1_prerelease is None and v2_prerelease is not None:
|
if v1_prerelease is None and v2_prerelease is not None:
|
||||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
return 1 # Version without pre-release tag is higher than one with it
|
||||||
if v1_prerelease is not None and v2_prerelease is None:
|
if v1_prerelease is not None and v2_prerelease is None:
|
||||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
return -1 # Version with pre-release tag is lower than one without it
|
||||||
if v1_prerelease is not None and v2_prerelease is not None:
|
if v1_prerelease is not None and v2_prerelease is not None:
|
||||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||||
for i in range(len_pre):
|
for i in range(len_pre):
|
||||||
@@ -72,9 +72,9 @@ class VersionComparator:
|
|||||||
return 1
|
return 1
|
||||||
if p1 < p2:
|
if p1 < p2:
|
||||||
return -1
|
return -1
|
||||||
return 0 # 预发布标签完全相同
|
return 0 # Pre-release tags are identical
|
||||||
|
|
||||||
return 0 # 数字部分和预发布标签都相同
|
return 0 # Both numeric parts and pre-release tags are equal
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_prerelease(prerelease):
|
def _split_prerelease(prerelease):
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from .utils.astrbot_path import get_astrbot_data_path
|
|||||||
# 初始化数据存储文件夹
|
# 初始化数据存储文件夹
|
||||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||||
|
|
||||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||||
|
|
||||||
astrbot_config = AstrBotConfig()
|
astrbot_config = AstrBotConfig()
|
||||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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(
|
llm_resp = await ctx.tool_loop_agent(
|
||||||
event=event,
|
event=event,
|
||||||
chat_provider_id=prov_id,
|
chat_provider_id=prov_id,
|
||||||
@@ -299,9 +302,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
|||||||
system_prompt=tool.agent.instructions,
|
system_prompt=tool.agent.instructions,
|
||||||
tools=toolset,
|
tools=toolset,
|
||||||
contexts=contexts,
|
contexts=contexts,
|
||||||
max_steps=30,
|
max_steps=agent_max_step,
|
||||||
run_hooks=tool.agent.run_hooks,
|
stream=stream,
|
||||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
|
||||||
)
|
)
|
||||||
yield mcp.types.CallToolResult(
|
yield mcp.types.CallToolResult(
|
||||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||||
|
|||||||
@@ -846,6 +846,8 @@ def _apply_sandbox_tools(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if req.func_tool is None:
|
if req.func_tool is None:
|
||||||
req.func_tool = ToolSet()
|
req.func_tool = ToolSet()
|
||||||
|
if req.system_prompt is None:
|
||||||
|
req.system_prompt = ""
|
||||||
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
||||||
if booter == "shipyard":
|
if booter == "shipyard":
|
||||||
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -41,8 +42,6 @@ def _discover_bay_credentials(endpoint: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
API key string, or empty string if not found.
|
API key string, or empty string if not found.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
|
|
||||||
candidates: list[Path] = []
|
candidates: list[Path] = []
|
||||||
|
|
||||||
# 1. BAY_DATA_DIR env var
|
# 1. BAY_DATA_DIR env var
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import platform
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import mcp
|
import mcp
|
||||||
@@ -10,6 +11,8 @@ 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.computer.tools.permissions import check_admin_permission
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
|
||||||
|
_OS_NAME = platform.system()
|
||||||
|
|
||||||
param_schema = {
|
param_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -61,7 +64,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
|||||||
@dataclass
|
@dataclass
|
||||||
class PythonTool(FunctionTool):
|
class PythonTool(FunctionTool):
|
||||||
name: str = "astrbot_execute_ipython"
|
name: str = "astrbot_execute_ipython"
|
||||||
description: str = "Run codes in an IPython shell."
|
description: str = f"Run codes in an IPython shell. Current OS: {_OS_NAME}."
|
||||||
parameters: dict = field(default_factory=lambda: param_schema)
|
parameters: dict = field(default_factory=lambda: param_schema)
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
@@ -83,7 +86,10 @@ class PythonTool(FunctionTool):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class LocalPythonTool(FunctionTool):
|
class LocalPythonTool(FunctionTool):
|
||||||
name: str = "astrbot_execute_python"
|
name: str = "astrbot_execute_python"
|
||||||
description: str = "Execute codes in a Python environment."
|
description: str = (
|
||||||
|
f"Execute codes in a Python environment. Current OS: {_OS_NAME}. "
|
||||||
|
"Use system-compatible commands."
|
||||||
|
)
|
||||||
|
|
||||||
parameters: dict = field(default_factory=lambda: param_schema)
|
parameters: dict = field(default_factory=lambda: param_schema)
|
||||||
|
|
||||||
|
|||||||
+116
-44
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.18.3"
|
VERSION = "4.19.2"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||||
@@ -395,7 +395,6 @@ CONFIG_METADATA_2 = {
|
|||||||
"discord_token": "",
|
"discord_token": "",
|
||||||
"discord_proxy": "",
|
"discord_proxy": "",
|
||||||
"discord_command_register": True,
|
"discord_command_register": True,
|
||||||
"discord_guild_id_for_debug": "",
|
|
||||||
"discord_activity_name": "",
|
"discord_activity_name": "",
|
||||||
},
|
},
|
||||||
"Misskey": {
|
"Misskey": {
|
||||||
@@ -450,6 +449,20 @@ CONFIG_METADATA_2 = {
|
|||||||
"satori_heartbeat_interval": 10,
|
"satori_heartbeat_interval": 10,
|
||||||
"satori_reconnect_delay": 5,
|
"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": {
|
# "WebChat": {
|
||||||
# "id": "webchat",
|
# "id": "webchat",
|
||||||
# "type": "webchat",
|
# "type": "webchat",
|
||||||
@@ -755,7 +768,8 @@ CONFIG_METADATA_2 = {
|
|||||||
"hint": "可选的代理地址:http://ip:port",
|
"hint": "可选的代理地址:http://ip:port",
|
||||||
},
|
},
|
||||||
"discord_command_register": {
|
"discord_command_register": {
|
||||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
|
"description": "注册 Discord 指令",
|
||||||
|
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
"discord_activity_name": {
|
"discord_activity_name": {
|
||||||
@@ -790,6 +804,51 @@ CONFIG_METADATA_2 = {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。",
|
"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": {
|
"platform_settings": {
|
||||||
@@ -1064,7 +1123,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"enable": True,
|
"enable": True,
|
||||||
"key": [],
|
"key": [],
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"api_base": "https://openrouter.ai/v1",
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"custom_headers": {},
|
"custom_headers": {},
|
||||||
},
|
},
|
||||||
@@ -3152,46 +3211,6 @@ CONFIG_METADATA_3 = {
|
|||||||
"provider_settings.agent_runner_type": "local",
|
"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": {
|
"provider_settings.max_agent_step": {
|
||||||
"description": "工具调用轮数上限",
|
"description": "工具调用轮数上限",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
@@ -3235,6 +3254,46 @@ CONFIG_METADATA_3 = {
|
|||||||
"type": "bool",
|
"type": "bool",
|
||||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
"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": {
|
"condition": {
|
||||||
"provider_settings.enable": True,
|
"provider_settings.enable": True,
|
||||||
@@ -3446,6 +3505,19 @@ CONFIG_METADATA_3 = {
|
|||||||
"platform_specific.telegram.pre_ack_emoji.enable": True,
|
"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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ class LogManager:
|
|||||||
_trace_sink_id: int | None = None
|
_trace_sink_id: int | None = None
|
||||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||||
"aiosqlite": logging.WARNING,
|
"aiosqlite": logging.WARNING,
|
||||||
|
"filelock": logging.WARNING,
|
||||||
|
"asyncio": logging.WARNING,
|
||||||
|
"tzlocal": logging.WARNING,
|
||||||
|
"apscheduler": logging.WARNING,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -539,13 +539,36 @@ class Reply(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Poke(BaseMessageComponent):
|
class Poke(BaseMessageComponent):
|
||||||
type: str = ComponentType.Poke
|
type: ComponentType = ComponentType.Poke
|
||||||
id: int | None = 0
|
_type: str | int = "126"
|
||||||
qq: int | None = 0
|
id: int | str | None = 0
|
||||||
|
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
|
||||||
|
|
||||||
def __init__(self, type: str, **_) -> None:
|
def __init__(self, poke_type: str | int | None = None, **_) -> None:
|
||||||
type = f"Poke:{type}"
|
# Backward compatible with old signature: Poke(type="poke", ...)
|
||||||
super().__init__(type=type, **_)
|
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}
|
||||||
|
|
||||||
|
|
||||||
class Forward(BaseMessageComponent):
|
class Forward(BaseMessageComponent):
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PreProcessStage(Stage):
|
|||||||
) -> None | AsyncGenerator[None, None]:
|
) -> None | AsyncGenerator[None, None]:
|
||||||
"""在处理事件之前的预处理"""
|
"""在处理事件之前的预处理"""
|
||||||
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
|
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
|
||||||
supported = {"telegram", "lark"}
|
supported = {"telegram", "lark", "discord"}
|
||||||
platform = event.get_platform_name()
|
platform = event.get_platform_name()
|
||||||
cfg = (
|
cfg = (
|
||||||
self.config.get("platform_specific", {})
|
self.config.get("platform_specific", {})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class RespondStage(Stage):
|
|||||||
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
||||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
|
||||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import traceback
|
|||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
from astrbot.core import file_token_service, html_renderer, logger
|
from astrbot.core import file_token_service, html_renderer, logger
|
||||||
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
|
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
|
||||||
from astrbot.core.message.message_event_result import ResultContentType
|
from astrbot.core.message.message_event_result import ResultContentType
|
||||||
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
||||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||||
@@ -383,8 +383,11 @@ class ResultDecorateStage(Stage):
|
|||||||
)
|
)
|
||||||
result.chain = [node]
|
result.chain = [node]
|
||||||
|
|
||||||
has_plain = any(isinstance(item, Plain) for item in result.chain)
|
# at 回复 / 引用回复仅适用于纯文本或图文消息
|
||||||
if has_plain:
|
can_decorate = all(
|
||||||
|
isinstance(item, (Plain, Image)) for item in result.chain
|
||||||
|
)
|
||||||
|
if can_decorate:
|
||||||
# at 回复
|
# at 回复
|
||||||
if (
|
if (
|
||||||
self.reply_with_mention
|
self.reply_with_mention
|
||||||
@@ -399,5 +402,4 @@ class ResultDecorateStage(Stage):
|
|||||||
|
|
||||||
# 引用回复
|
# 引用回复
|
||||||
if self.reply_with_quote:
|
if self.reply_with_quote:
|
||||||
if not any(isinstance(item, File) for item in result.chain):
|
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ class PlatformManager:
|
|||||||
from .sources.line.line_adapter import (
|
from .sources.line.line_adapter import (
|
||||||
LinePlatformAdapter, # noqa: F401
|
LinePlatformAdapter, # noqa: F401
|
||||||
)
|
)
|
||||||
|
case "kook":
|
||||||
|
from .sources.kook.kook_adapter import (
|
||||||
|
KookPlatformAdapter, # noqa: F401
|
||||||
|
)
|
||||||
except (ImportError, ModuleNotFoundError) as e:
|
except (ImportError, ModuleNotFoundError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
|
|||||||
|
|
||||||
if "sub_type" in event:
|
if "sub_type" in event:
|
||||||
if event["sub_type"] == "poke" and "target_id" in event:
|
if event["sub_type"] == "poke" and "target_id" in event:
|
||||||
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
|
abm.message.append(Poke(id=str(event["target_id"])))
|
||||||
|
|
||||||
return abm
|
return abm
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
|
|||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.api.event import MessageChain
|
from astrbot.api.event import MessageChain
|
||||||
from astrbot.api.message_components import At, Image, Plain, Record, Video
|
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||||
from astrbot.api.platform import (
|
from astrbot.api.platform import (
|
||||||
AstrBotMessage,
|
AstrBotMessage,
|
||||||
MessageMember,
|
MessageMember,
|
||||||
@@ -178,29 +178,110 @@ class DingtalkPlatformAdapter(Platform):
|
|||||||
abm.session_id = abm.sender.user_id
|
abm.session_id = abm.sender.user_id
|
||||||
|
|
||||||
message_type: str = cast(str, message.message_type)
|
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:
|
match message_type:
|
||||||
case "text":
|
case "text":
|
||||||
abm.message_str = message.text.content.strip()
|
abm.message_str = message.text.content.strip()
|
||||||
abm.message.append(Plain(abm.message_str))
|
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":
|
case "richText":
|
||||||
rtc: dingtalk_stream.RichTextContent = cast(
|
rtc: dingtalk_stream.RichTextContent = cast(
|
||||||
dingtalk_stream.RichTextContent, message.rich_text_content
|
dingtalk_stream.RichTextContent, message.rich_text_content
|
||||||
)
|
)
|
||||||
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
|
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
|
||||||
|
plain_parts: list[str] = []
|
||||||
for content in contents:
|
for content in contents:
|
||||||
plains = ""
|
|
||||||
if "text" in content:
|
if "text" in content:
|
||||||
plains += content["text"]
|
plain_text = cast(str, content.get("text") or "")
|
||||||
abm.message.append(Plain(plains))
|
if plain_text:
|
||||||
|
plain_parts.append(plain_text)
|
||||||
|
abm.message.append(Plain(plain_text))
|
||||||
elif "type" in content and content["type"] == "picture":
|
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(
|
f_path = await self.download_ding_file(
|
||||||
content["downloadCode"],
|
download_code,
|
||||||
cast(str, message.robot_code),
|
robot_code,
|
||||||
"jpg",
|
"jpg",
|
||||||
)
|
)
|
||||||
abm.message.append(Image.fromFileSystem(f_path))
|
if f_path:
|
||||||
case "audio":
|
abm.message.append(Image.fromFileSystem(f_path))
|
||||||
pass
|
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))
|
||||||
|
|
||||||
await self._remember_sender_binding(message, abm)
|
await self._remember_sender_binding(message, abm)
|
||||||
return abm # 别忘了返回转换后的消息对象
|
return abm # 别忘了返回转换后的消息对象
|
||||||
@@ -270,7 +351,17 @@ class DingtalkPlatformAdapter(Platform):
|
|||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
resp_data = await resp.json()
|
resp_data = await resp.json()
|
||||||
download_url = resp_data["data"]["downloadUrl"]
|
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 ""
|
||||||
await download_file(download_url, str(f_path))
|
await download_file(download_url, str(f_path))
|
||||||
return str(f_path)
|
return str(f_path)
|
||||||
|
|
||||||
@@ -541,6 +632,28 @@ class DingtalkPlatformAdapter(Platform):
|
|||||||
self._safe_remove_file(cover_path)
|
self._safe_remove_file(cover_path)
|
||||||
if converted_video:
|
if converted_video:
|
||||||
self._safe_remove_file(video_path)
|
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(
|
async def send_message_chain_to_group(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
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] 连接已关闭")
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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, # 速率限制窗口(秒)
|
||||||
|
# }
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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 = ""
|
||||||
@@ -104,7 +104,7 @@ class LineMessageEvent(AstrMessageEvent):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_image_url(segment: Image) -> str:
|
async def _resolve_image_url(segment: Image) -> str:
|
||||||
candidate = (segment.url or segment.file or "").strip()
|
candidate = (segment.url or segment.file or "").strip()
|
||||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
if candidate.startswith("https://"):
|
||||||
return candidate
|
return candidate
|
||||||
try:
|
try:
|
||||||
return await segment.register_to_file_service()
|
return await segment.register_to_file_service()
|
||||||
@@ -115,7 +115,7 @@ class LineMessageEvent(AstrMessageEvent):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_record_url(segment: Record) -> str:
|
async def _resolve_record_url(segment: Record) -> str:
|
||||||
candidate = (segment.url or segment.file or "").strip()
|
candidate = (segment.url or segment.file or "").strip()
|
||||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
if candidate.startswith("https://"):
|
||||||
return candidate
|
return candidate
|
||||||
try:
|
try:
|
||||||
return await segment.register_to_file_service()
|
return await segment.register_to_file_service()
|
||||||
@@ -137,7 +137,7 @@ class LineMessageEvent(AstrMessageEvent):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_video_url(segment: Video) -> str:
|
async def _resolve_video_url(segment: Video) -> str:
|
||||||
candidate = (segment.file or "").strip()
|
candidate = (segment.file or "").strip()
|
||||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
if candidate.startswith("https://"):
|
||||||
return candidate
|
return candidate
|
||||||
try:
|
try:
|
||||||
return await segment.register_to_file_service()
|
return await segment.register_to_file_service()
|
||||||
@@ -148,9 +148,7 @@ class LineMessageEvent(AstrMessageEvent):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_video_preview_url(segment: Video) -> str:
|
async def _resolve_video_preview_url(segment: Video) -> str:
|
||||||
cover_candidate = (segment.cover or "").strip()
|
cover_candidate = (segment.cover or "").strip()
|
||||||
if cover_candidate.startswith("http://") or cover_candidate.startswith(
|
if cover_candidate.startswith("https://"):
|
||||||
"https://"
|
|
||||||
):
|
|
||||||
return cover_candidate
|
return cover_candidate
|
||||||
|
|
||||||
if cover_candidate:
|
if cover_candidate:
|
||||||
@@ -191,7 +189,7 @@ class LineMessageEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_file_url(segment: File) -> str:
|
async def _resolve_file_url(segment: File) -> str:
|
||||||
if segment.url and segment.url.startswith(("http://", "https://")):
|
if segment.url and segment.url.startswith("https://"):
|
||||||
return segment.url
|
return segment.url
|
||||||
try:
|
try:
|
||||||
return await segment.register_to_file_service()
|
return await segment.register_to_file_service()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import telegramify_markdown
|
import telegramify_markdown
|
||||||
@@ -21,6 +22,7 @@ from astrbot.api.message_components import (
|
|||||||
Video,
|
Video,
|
||||||
)
|
)
|
||||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||||
|
from astrbot.core.utils.metrics import Metric
|
||||||
|
|
||||||
|
|
||||||
class TelegramPlatformEvent(AstrMessageEvent):
|
class TelegramPlatformEvent(AstrMessageEvent):
|
||||||
@@ -34,6 +36,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
"word": re.compile(r"\s"),
|
"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 的映射,用于优先级判断
|
# 消息类型到 chat action 的映射,用于优先级判断
|
||||||
ACTION_BY_TYPE: dict[type, str] = {
|
ACTION_BY_TYPE: dict[type, str] = {
|
||||||
Record: ChatAction.UPLOAD_VOICE,
|
Record: ChatAction.UPLOAD_VOICE,
|
||||||
@@ -339,6 +355,118 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Telegram] 添加反应失败: {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):
|
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||||
message_thread_id = None
|
message_thread_id = None
|
||||||
|
|
||||||
@@ -356,6 +484,138 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
if message_thread_id:
|
if message_thread_id:
|
||||||
payload["message_thread_id"] = 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 = ""
|
delta = ""
|
||||||
current_content = ""
|
current_content = ""
|
||||||
message_id = None
|
message_id = None
|
||||||
@@ -368,121 +628,67 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
await self._ensure_typing(user_name, message_thread_id)
|
await self._ensure_typing(user_name, message_thread_id)
|
||||||
last_chat_action_time = asyncio.get_event_loop().time()
|
last_chat_action_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
def _append_text(t: str) -> None:
|
||||||
|
nonlocal delta
|
||||||
|
delta += t
|
||||||
|
|
||||||
async for chain in generator:
|
async for chain in generator:
|
||||||
if isinstance(chain, MessageChain):
|
if not isinstance(chain, MessageChain):
|
||||||
if chain.type == "break":
|
continue
|
||||||
# 分割符
|
|
||||||
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":
|
||||||
for i in chain.chain:
|
# 分割符
|
||||||
if isinstance(i, Plain):
|
if message_id:
|
||||||
delta += i.text
|
try:
|
||||||
elif isinstance(i, Image):
|
await self.client.edit_message_text(
|
||||||
image_path = await i.convert_to_file_path()
|
text=delta,
|
||||||
await self._send_media_with_action(
|
chat_id=payload["chat_id"],
|
||||||
self.client,
|
message_id=message_id,
|
||||||
ChatAction.UPLOAD_PHOTO,
|
|
||||||
self.client.send_photo,
|
|
||||||
user_name=user_name,
|
|
||||||
photo=image_path,
|
|
||||||
**cast(Any, payload),
|
|
||||||
)
|
)
|
||||||
continue
|
except Exception as e:
|
||||||
elif isinstance(i, File):
|
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||||
path = await i.get_file()
|
message_id = None
|
||||||
name = i.name or os.path.basename(path)
|
delta = ""
|
||||||
await self._send_media_with_action(
|
continue
|
||||||
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
|
|
||||||
|
|
||||||
# Plain
|
await self._process_chain_items(
|
||||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
chain, payload, user_name, message_thread_id, _append_text
|
||||||
current_time = asyncio.get_event_loop().time()
|
)
|
||||||
time_since_last_edit = current_time - last_edit_time
|
|
||||||
|
|
||||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
# 编辑或发送消息
|
||||||
if time_since_last_edit >= throttle_interval:
|
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||||
# 发送 typing 状态(带节流)
|
current_time = asyncio.get_event_loop().time()
|
||||||
current_time = asyncio.get_event_loop().time()
|
time_since_last_edit = current_time - last_edit_time
|
||||||
if current_time - last_chat_action_time >= chat_action_interval:
|
|
||||||
await self._ensure_typing(user_name, message_thread_id)
|
if time_since_last_edit >= throttle_interval:
|
||||||
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()
|
current_time = asyncio.get_event_loop().time()
|
||||||
if current_time - last_chat_action_time >= chat_action_interval:
|
if current_time - last_chat_action_time >= chat_action_interval:
|
||||||
await self._ensure_typing(user_name, message_thread_id)
|
await self._ensure_typing(user_name, message_thread_id)
|
||||||
last_chat_action_time = current_time
|
last_chat_action_time = current_time
|
||||||
try:
|
try:
|
||||||
msg = await self.client.send_message(
|
await self.client.edit_message_text(
|
||||||
text=delta, **cast(Any, payload)
|
text=delta,
|
||||||
|
chat_id=payload["chat_id"],
|
||||||
|
message_id=message_id,
|
||||||
)
|
)
|
||||||
current_content = delta
|
current_content = delta
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||||
message_id = msg.message_id
|
last_edit_time = asyncio.get_event_loop().time()
|
||||||
last_edit_time = (
|
else:
|
||||||
asyncio.get_event_loop().time()
|
current_time = asyncio.get_event_loop().time()
|
||||||
) # 记录初始消息发送时间
|
if current_time - last_chat_action_time >= chat_action_interval:
|
||||||
|
await self._ensure_typing(user_name, message_thread_id)
|
||||||
|
last_chat_action_time = current_time
|
||||||
|
try:
|
||||||
|
msg = await self.client.send_message(
|
||||||
|
text=delta, **cast(Any, payload)
|
||||||
|
)
|
||||||
|
current_content = delta
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||||
|
message_id = msg.message_id
|
||||||
|
last_edit_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if delta and current_content != delta:
|
if delta and current_content != delta:
|
||||||
@@ -506,5 +712,3 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||||
|
|
||||||
return await super().send_streaming(generator, use_fallback)
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -17,6 +21,103 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
|
|
||||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||||
|
|
||||||
|
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
|
||||||
|
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.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 = [
|
SUPPORTED_TYPES = [
|
||||||
"string",
|
"string",
|
||||||
"number",
|
"number",
|
||||||
@@ -106,9 +207,49 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
|||||||
class FunctionToolManager:
|
class FunctionToolManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.func_list: list[FuncTool] = []
|
self.func_list: list[FuncTool] = []
|
||||||
self.mcp_client_dict: dict[str, MCPClient] = {}
|
self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {}
|
||||||
"""MCP 服务列表"""
|
"""MCP 服务运行时状态(唯一事实来源)"""
|
||||||
self.mcp_client_event: dict[str, asyncio.Event] = {}
|
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
|
||||||
|
|
||||||
def empty(self) -> bool:
|
def empty(self) -> bool:
|
||||||
return len(self.func_list) == 0
|
return len(self.func_list) == 0
|
||||||
@@ -179,7 +320,34 @@ class FunctionToolManager:
|
|||||||
tool_set = ToolSet(self.func_list.copy())
|
tool_set = ToolSet(self.func_list.copy())
|
||||||
return tool_set
|
return tool_set
|
||||||
|
|
||||||
async def init_mcp_clients(self) -> None:
|
@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:
|
||||||
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@@ -197,6 +365,10 @@ class FunctionToolManager:
|
|||||||
...
|
...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Timeout behavior:
|
||||||
|
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
|
||||||
|
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
|
||||||
"""
|
"""
|
||||||
data_dir = get_astrbot_data_path()
|
data_dir = get_astrbot_data_path()
|
||||||
|
|
||||||
@@ -206,56 +378,211 @@ class FunctionToolManager:
|
|||||||
with open(mcp_json_file, "w", encoding="utf-8") as f:
|
with open(mcp_json_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||||
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
||||||
return
|
return MCPInitSummary(total=0, success=0, failed=[])
|
||||||
|
|
||||||
mcp_server_json_obj: dict[str, dict] = json.load(
|
with open(mcp_json_file, encoding="utf-8") as f:
|
||||||
open(mcp_json_file, encoding="utf-8"),
|
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
|
||||||
)["mcpServers"]
|
|
||||||
|
|
||||||
for name in mcp_server_json_obj:
|
init_timeout = self._init_timeout_default
|
||||||
cfg = mcp_server_json_obj[name]
|
timeout_display = f"{init_timeout:g}"
|
||||||
|
|
||||||
|
active_configs: list[tuple[str, dict, asyncio.Event]] = []
|
||||||
|
for name, cfg in mcp_server_json_obj.items():
|
||||||
if cfg.get("active", True):
|
if cfg.get("active", True):
|
||||||
event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
asyncio.create_task(
|
active_configs.append((name, cfg, shutdown_event))
|
||||||
self._init_mcp_client_task_wrapper(name, cfg, event),
|
|
||||||
)
|
|
||||||
self.mcp_client_event[name] = event
|
|
||||||
|
|
||||||
async def _init_mcp_client_task_wrapper(
|
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"MCP 服务 {name} 初始化超时({timeout_display}秒)")
|
||||||
|
else:
|
||||||
|
logger.error(f"MCP 服务 {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"以下 MCP 服务初始化失败: {', '.join(failed_services)}。"
|
||||||
|
f"请检查配置文件 mcp_server.json 和服务器可用性。"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = MCPInitSummary(
|
||||||
|
total=len(active_configs), success=success_count, failed=failed_services
|
||||||
|
)
|
||||||
|
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
|
||||||
|
if summary.total > 0 and summary.success == 0:
|
||||||
|
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
|
||||||
|
if raise_on_all_failed:
|
||||||
|
raise MCPAllServicesFailedError(msg)
|
||||||
|
logger.error(msg)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
async def _start_mcp_server(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
cfg: dict,
|
cfg: dict,
|
||||||
event: asyncio.Event,
|
*,
|
||||||
ready_future: asyncio.Future | None = None,
|
shutdown_event: asyncio.Event | None = None,
|
||||||
|
timeout: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
|
"""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"MCP 服务 {name} 已在运行,忽略本次启用请求(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
|
||||||
try:
|
try:
|
||||||
await self._init_mcp_client(name, cfg)
|
mcp_client = await asyncio.wait_for(
|
||||||
tools = await self.mcp_client_dict[name].list_tools_and_save()
|
self._init_mcp_client(name, cfg),
|
||||||
if ready_future and not ready_future.done():
|
timeout=timeout,
|
||||||
# tell the caller we are ready
|
)
|
||||||
ready_future.set_result(tools)
|
except asyncio.TimeoutError as exc:
|
||||||
await event.wait()
|
raise MCPInitTimeoutError(
|
||||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
|
||||||
except Exception as e:
|
) from exc
|
||||||
|
except Exception:
|
||||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||||
if ready_future and not ready_future.done():
|
raise
|
||||||
ready_future.set_exception(e)
|
|
||||||
finally:
|
finally:
|
||||||
# 无论如何都能清理
|
if mcp_client is None:
|
||||||
await self._terminate_mcp_client(name)
|
async with self._runtime_lock:
|
||||||
|
self._mcp_starting.discard(name)
|
||||||
|
|
||||||
async def _init_mcp_client(self, name: str, config: dict) -> None:
|
async def lifecycle() -> None:
|
||||||
|
try:
|
||||||
|
await shutdown_event.wait()
|
||||||
|
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(f"MCP 客户端 {name} 任务被取消")
|
||||||
|
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 服务关闭超时(%s 秒),以下服务未完全关闭:%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"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
|
||||||
|
|
||||||
|
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||||
"""初始化单个MCP客户端"""
|
"""初始化单个MCP客户端"""
|
||||||
# 先清理之前的客户端,如果存在
|
|
||||||
if name in self.mcp_client_dict:
|
|
||||||
await self._terminate_mcp_client(name)
|
|
||||||
|
|
||||||
mcp_client = MCPClient()
|
mcp_client = MCPClient()
|
||||||
mcp_client.name = name
|
mcp_client.name = name
|
||||||
self.mcp_client_dict[name] = mcp_client
|
try:
|
||||||
await mcp_client.connect_to_server(config, name)
|
await mcp_client.connect_to_server(config, name)
|
||||||
tools_res = await mcp_client.list_tools_and_save()
|
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
|
||||||
logger.debug(f"MCP server {name} list tools response: {tools_res}")
|
logger.debug(f"MCP server {name} list tools response: {tools_res}")
|
||||||
tool_names = [tool.name for tool in tools_res.tools]
|
tool_names = [tool.name for tool in tools_res.tools]
|
||||||
|
|
||||||
@@ -276,26 +603,36 @@ class FunctionToolManager:
|
|||||||
self.func_list.append(func_tool)
|
self.func_list.append(func_tool)
|
||||||
|
|
||||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||||
|
return mcp_client
|
||||||
|
|
||||||
async def _terminate_mcp_client(self, name: str) -> None:
|
async def _terminate_mcp_client(self, name: str) -> None:
|
||||||
"""关闭并清理MCP客户端"""
|
"""关闭并清理MCP客户端"""
|
||||||
if name in self.mcp_client_dict:
|
async with self._runtime_lock:
|
||||||
client = self.mcp_client_dict[name]
|
runtime = self._mcp_server_runtime.get(name)
|
||||||
try:
|
if runtime:
|
||||||
# 关闭MCP连接
|
client = runtime.client
|
||||||
await client.cleanup()
|
# 关闭MCP连接
|
||||||
except Exception as e:
|
await self._cleanup_mcp_client_safely(client, name)
|
||||||
logger.error(f"清空 MCP 客户端资源 {name}: {e}。")
|
# 移除关联的FuncTool
|
||||||
finally:
|
self.func_list = [
|
||||||
# Remove client from dict after cleanup attempt (successful or not)
|
f
|
||||||
self.mcp_client_dict.pop(name, None)
|
for f in self.func_list
|
||||||
# 移除关联的FuncTool
|
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||||
self.func_list = [
|
]
|
||||||
f
|
async with self._runtime_lock:
|
||||||
for f in self.func_list
|
self._mcp_server_runtime.pop(name, None)
|
||||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
self._mcp_starting.discard(name)
|
||||||
]
|
logger.info(f"已关闭 MCP 服务 {name}")
|
||||||
logger.info(f"已关闭 MCP 服务 {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)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def test_mcp_server_connection(config: dict) -> list[str]:
|
async def test_mcp_server_connection(config: dict) -> list[str]:
|
||||||
@@ -319,42 +656,36 @@ class FunctionToolManager:
|
|||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
config: dict,
|
config: dict,
|
||||||
event: asyncio.Event | None = None,
|
shutdown_event: asyncio.Event | None = None,
|
||||||
ready_future: asyncio.Future | None = None,
|
timeout: float | int | str | None = None,
|
||||||
timeout: int = 30,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enable_mcp_server a new MCP server to the manager and initialize it.
|
"""Enable a new MCP server and initialize it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): The name of the MCP server.
|
name: The name of the MCP server.
|
||||||
config (dict): Configuration for the MCP server.
|
config: Configuration for the MCP server.
|
||||||
event (asyncio.Event): Event to signal when the MCP client is ready.
|
shutdown_event: Event to signal when the MCP client should shut down.
|
||||||
ready_future (asyncio.Future): Future to signal when the MCP client is ready.
|
timeout: Timeout in seconds for initialization.
|
||||||
timeout (int): Timeout for the initialization.
|
Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the initialization does not complete within the specified timeout.
|
MCPInitTimeoutError: If initialization does not complete within timeout.
|
||||||
Exception: If there is an error during initialization.
|
Exception: If there is an error during initialization.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not event:
|
if timeout is None:
|
||||||
event = asyncio.Event()
|
timeout_value = self._enable_timeout_default
|
||||||
if not ready_future:
|
else:
|
||||||
ready_future = asyncio.Future()
|
timeout_value = _resolve_timeout(
|
||||||
if name in self.mcp_client_dict:
|
timeout=timeout,
|
||||||
return
|
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||||
asyncio.create_task(
|
default=self._enable_timeout_default,
|
||||||
self._init_mcp_client_task_wrapper(name, config, event, ready_future),
|
)
|
||||||
|
await self._start_mcp_server(
|
||||||
|
name=name,
|
||||||
|
cfg=config,
|
||||||
|
shutdown_event=shutdown_event,
|
||||||
|
timeout=timeout_value,
|
||||||
)
|
)
|
||||||
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(
|
async def disable_mcp_server(
|
||||||
self,
|
self,
|
||||||
@@ -367,39 +698,40 @@ class FunctionToolManager:
|
|||||||
name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled.
|
name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled.
|
||||||
timeout (int): Timeout.
|
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:
|
if name:
|
||||||
if name not in self.mcp_client_event:
|
async with self._runtime_lock:
|
||||||
|
runtime = self._mcp_server_runtime.get(name)
|
||||||
|
if runtime is None:
|
||||||
return
|
return
|
||||||
client = self.mcp_client_dict.get(name)
|
|
||||||
self.mcp_client_event[name].set()
|
await self._shutdown_runtimes([runtime], timeout, strict=True)
|
||||||
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:
|
else:
|
||||||
running_events = [
|
async with self._runtime_lock:
|
||||||
client.running_event.wait() for client in self.mcp_client_dict.values()
|
runtimes = list(self._mcp_server_runtime.values())
|
||||||
]
|
await self._shutdown_runtimes(runtimes, timeout, strict=False)
|
||||||
for key, event in self.mcp_client_event.items():
|
|
||||||
event.set()
|
def _warn_on_timeout_mismatch(
|
||||||
# waiting for all clients to finish
|
self,
|
||||||
try:
|
init_timeout: float,
|
||||||
await asyncio.wait_for(asyncio.gather(*running_events), timeout=timeout)
|
enable_timeout: float,
|
||||||
finally:
|
) -> None:
|
||||||
self.mcp_client_event.clear()
|
if init_timeout == enable_timeout:
|
||||||
self.mcp_client_dict.clear()
|
return
|
||||||
self.func_list = [
|
with self._timeout_warn_lock:
|
||||||
f for f in self.func_list if not isinstance(f, MCPTool)
|
if self._timeout_mismatch_warned:
|
||||||
]
|
return
|
||||||
|
logger.info(
|
||||||
|
"检测到 MCP 初始化超时与动态启用超时配置不同:"
|
||||||
|
"初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。",
|
||||||
|
f"{init_timeout:g}",
|
||||||
|
f"{enable_timeout:g}",
|
||||||
|
)
|
||||||
|
self._timeout_mismatch_warned = True
|
||||||
|
|
||||||
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
||||||
"""获得 OpenAI API 风格的**已经激活**的工具描述"""
|
"""获得 OpenAI API 风格的**已经激活**的工具描述"""
|
||||||
|
|||||||
@@ -330,8 +330,25 @@ class ProviderManager:
|
|||||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||||
|
|
||||||
# 初始化 MCP Client 连接
|
# 初始化 MCP Client 连接(等待完成以确保工具可用)
|
||||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
mcp_init_summary = await self.llm_tools.init_mcp_clients(
|
||||||
|
raise_on_all_failed=strict_mcp_init
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
mcp_init_summary.total > 0
|
||||||
|
and mcp_init_summary.success == 0
|
||||||
|
and not strict_mcp_init
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
|
||||||
|
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
|
||||||
|
)
|
||||||
|
|
||||||
def dynamic_import_provider(self, type: str) -> None:
|
def dynamic_import_provider(self, type: str) -> None:
|
||||||
"""动态导入提供商适配器模块
|
"""动态导入提供商适配器模块
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
|
|||||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
_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
|
@dataclass
|
||||||
class SkillInfo:
|
class SkillInfo:
|
||||||
name: str
|
name: str
|
||||||
@@ -401,7 +408,11 @@ class SkillManager:
|
|||||||
raise ValueError("Uploaded file is not a valid zip archive.")
|
raise ValueError("Uploaded file is not a valid zip archive.")
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
names = [name.replace("\\", "/") for name in zf.namelist()]
|
names = [
|
||||||
|
name
|
||||||
|
for name in (entry.replace("\\", "/") for entry in zf.namelist())
|
||||||
|
if name and not _is_ignored_zip_entry(name)
|
||||||
|
]
|
||||||
file_names = [name for name in names if name and not name.endswith("/")]
|
file_names = [name for name in names if name and not name.endswith("/")]
|
||||||
if not file_names:
|
if not file_names:
|
||||||
raise ValueError("Zip archive is empty.")
|
raise ValueError("Zip archive is empty.")
|
||||||
@@ -436,7 +447,11 @@ class SkillManager:
|
|||||||
raise ValueError("SKILL.md not found in the skill folder.")
|
raise ValueError("SKILL.md not found in the skill folder.")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
||||||
zf.extractall(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)
|
||||||
src_dir = Path(tmp_dir) / skill_name
|
src_dir = Path(tmp_dir) / skill_name
|
||||||
if not src_dir.exists():
|
if not src_dir.exists():
|
||||||
raise ValueError("Skill folder not found after extraction.")
|
raise ValueError("Skill folder not found after extraction.")
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ class RegexFilter(HandlerFilter):
|
|||||||
self.regex = re.compile(regex)
|
self.regex = re.compile(regex)
|
||||||
|
|
||||||
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
|
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
|
||||||
return bool(self.regex.match(event.get_message_str().strip()))
|
return bool(self.regex.search(event.get_message_str().strip()))
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ class AstrBotUpdator(RepoZipUpdator):
|
|||||||
file_url = None
|
file_url = None
|
||||||
|
|
||||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
raise Exception(
|
||||||
|
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
|
||||||
|
) # 避免版本管理混乱
|
||||||
|
|
||||||
if latest:
|
if latest:
|
||||||
latest_version = update_data[0]["tag_name"]
|
latest_version = update_data[0]["tag_name"]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import certifi
|
|||||||
import psutil
|
import psutil
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
|
from .astrbot_path import get_astrbot_data_path, get_astrbot_path, get_astrbot_temp_path
|
||||||
|
|
||||||
logger = logging.getLogger("astrbot")
|
logger = logging.getLogger("astrbot")
|
||||||
|
|
||||||
@@ -219,7 +219,13 @@ def get_local_ip_addresses():
|
|||||||
|
|
||||||
|
|
||||||
async def get_dashboard_version():
|
async def get_dashboard_version():
|
||||||
|
# First check user data directory (manually updated / downloaded dashboard).
|
||||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
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):
|
if os.path.exists(dist_dir):
|
||||||
version_file = os.path.join(dist_dir, "assets", "version")
|
version_file = os.path.join(dist_dir, "assets", "version")
|
||||||
if os.path.exists(version_file):
|
if os.path.exists(version_file):
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_packaged_desktop_runtime() -> bool:
|
def is_packaged_desktop_runtime() -> bool:
|
||||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ class ConfigRoute(Route):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
conf_id = self.acm.create_conf(name=name, config=config)
|
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__
|
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return Response().error(str(e)).__dict__
|
return Response().error(str(e)).__dict__
|
||||||
@@ -649,6 +650,7 @@ class ConfigRoute(Route):
|
|||||||
try:
|
try:
|
||||||
success = self.acm.delete_conf(conf_id)
|
success = self.acm.delete_conf(conf_id)
|
||||||
if success:
|
if success:
|
||||||
|
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
|
||||||
return Response().ok(message="删除成功").__dict__
|
return Response().ok(message="删除成功").__dict__
|
||||||
return Response().error("删除失败").__dict__
|
return Response().error("删除失败").__dict__
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -50,6 +51,7 @@ class SkillsRoute(Route):
|
|||||||
self.routes = {
|
self.routes = {
|
||||||
"/skills": ("GET", self.get_skills),
|
"/skills": ("GET", self.get_skills),
|
||||||
"/skills/upload": ("POST", self.upload_skill),
|
"/skills/upload": ("POST", self.upload_skill),
|
||||||
|
"/skills/batch-upload": ("POST", self.batch_upload_skills),
|
||||||
"/skills/download": ("GET", self.download_skill),
|
"/skills/download": ("GET", self.download_skill),
|
||||||
"/skills/update": ("POST", self.update_skill),
|
"/skills/update": ("POST", self.update_skill),
|
||||||
"/skills/delete": ("POST", self.delete_skill),
|
"/skills/delete": ("POST", self.delete_skill),
|
||||||
@@ -188,6 +190,114 @@ class SkillsRoute(Route):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Failed to remove temp skill file: {temp_path}")
|
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):
|
async def download_skill(self):
|
||||||
try:
|
try:
|
||||||
name = str(request.args.get("name") or "").strip()
|
name = str(request.args.get("name") or "").strip()
|
||||||
|
|||||||
@@ -51,11 +51,9 @@ class ToolsRoute(Route):
|
|||||||
server_info[key] = value
|
server_info[key] = value
|
||||||
|
|
||||||
# 如果MCP客户端已初始化,从客户端获取工具名称
|
# 如果MCP客户端已初始化,从客户端获取工具名称
|
||||||
for (
|
for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items():
|
||||||
name_key,
|
|
||||||
mcp_client,
|
|
||||||
) in self.tool_mgr.mcp_client_dict.items():
|
|
||||||
if name_key == name:
|
if name_key == name:
|
||||||
|
mcp_client = runtime.client
|
||||||
server_info["tools"] = [tool.name for tool in mcp_client.tools]
|
server_info["tools"] = [tool.name for tool in mcp_client.tools]
|
||||||
server_info["errlogs"] = mcp_client.server_errlogs
|
server_info["errlogs"] = mcp_client.server_errlogs
|
||||||
break
|
break
|
||||||
@@ -192,7 +190,7 @@ class ToolsRoute(Route):
|
|||||||
# 处理MCP客户端状态变化
|
# 处理MCP客户端状态变化
|
||||||
if active:
|
if active:
|
||||||
if (
|
if (
|
||||||
old_name in self.tool_mgr.mcp_client_dict
|
old_name in self.tool_mgr.mcp_server_runtime_view
|
||||||
or not only_update_active
|
or not only_update_active
|
||||||
or is_rename
|
or is_rename
|
||||||
):
|
):
|
||||||
@@ -233,7 +231,7 @@ class ToolsRoute(Route):
|
|||||||
.__dict__
|
.__dict__
|
||||||
)
|
)
|
||||||
# 如果要停用服务器
|
# 如果要停用服务器
|
||||||
elif old_name in self.tool_mgr.mcp_client_dict:
|
elif old_name in self.tool_mgr.mcp_server_runtime_view:
|
||||||
try:
|
try:
|
||||||
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
|
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
@@ -272,7 +270,7 @@ class ToolsRoute(Route):
|
|||||||
del config["mcpServers"][name]
|
del config["mcpServers"][name]
|
||||||
|
|
||||||
if self.tool_mgr.save_mcp_config(config):
|
if self.tool_mgr.save_mcp_config(config):
|
||||||
if name in self.tool_mgr.mcp_client_dict:
|
if name in self.tool_mgr.mcp_server_runtime_view:
|
||||||
try:
|
try:
|
||||||
await self.tool_mgr.disable_mcp_server(name, timeout=10)
|
await self.tool_mgr.disable_mcp_server(name, timeout=10)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ from .routes.session_management import SessionManagementRoute
|
|||||||
from .routes.subagent import SubAgentRoute
|
from .routes.subagent import SubAgentRoute
|
||||||
from .routes.t2i import T2iRoute
|
from .routes.t2i import T2iRoute
|
||||||
|
|
||||||
|
# Static assets shipped inside the wheel (built during `hatch build`).
|
||||||
|
_BUNDLED_DIST = Path(__file__).parent / "dist"
|
||||||
|
|
||||||
|
|
||||||
class _AddrWithPort(Protocol):
|
class _AddrWithPort(Protocol):
|
||||||
port: int
|
port: int
|
||||||
@@ -66,13 +69,22 @@ class AstrBotDashboard:
|
|||||||
self.config = core_lifecycle.astrbot_config
|
self.config = core_lifecycle.astrbot_config
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
# 参数指定webui目录
|
# Path priority:
|
||||||
|
# 1. Explicit webui_dir argument
|
||||||
|
# 2. data/dist/ (user-installed / manually updated dashboard)
|
||||||
|
# 3. astrbot/dashboard/dist/ (bundled with the wheel)
|
||||||
if webui_dir and os.path.exists(webui_dir):
|
if webui_dir and os.path.exists(webui_dir):
|
||||||
self.data_path = os.path.abspath(webui_dir)
|
self.data_path = os.path.abspath(webui_dir)
|
||||||
else:
|
else:
|
||||||
self.data_path = os.path.abspath(
|
user_dist = os.path.join(get_astrbot_data_path(), "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.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||||
APP = self.app # noqa
|
APP = self.app # noqa
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
## 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`.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.svg" />
|
<link rel="icon" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="keywords" content="AstrBot Soulter" />
|
<meta name="keywords" content="AstrBot Soulter" />
|
||||||
<meta name="description" content="AstrBot Dashboard" />
|
<meta name="description" content="AstrBot Dashboard" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|||||||
@@ -37,14 +37,7 @@
|
|||||||
|
|
||||||
<!-- 正常聊天界面 -->
|
<!-- 正常聊天界面 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="conversation-header fade-in" v-if="isMobile">
|
|
||||||
<!-- 手机端菜单按钮 -->
|
|
||||||
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
|
|
||||||
<v-icon>mdi-menu</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 面包屑导航 -->
|
|
||||||
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
||||||
<div class="breadcrumb-content">
|
<div class="breadcrumb-content">
|
||||||
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
|
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
|
||||||
@@ -241,6 +234,7 @@ const route = useRoute();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tm } = useModuleI18n('features/chat');
|
const { tm } = useModuleI18n('features/chat');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const customizer = useCustomizerStore();
|
||||||
|
|
||||||
// UI 状态
|
// UI 状态
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
@@ -342,19 +336,28 @@ function checkMobile() {
|
|||||||
isMobile.value = window.innerWidth <= 768;
|
isMobile.value = window.innerWidth <= 768;
|
||||||
if (!isMobile.value) {
|
if (!isMobile.value) {
|
||||||
mobileMenuOpen.value = false;
|
mobileMenuOpen.value = false;
|
||||||
|
customizer.SET_CHAT_SIDEBAR(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMobileSidebar() {
|
function toggleMobileSidebar() {
|
||||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||||
|
customizer.SET_CHAT_SIDEBAR(mobileMenuOpen.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMobileSidebar() {
|
function closeMobileSidebar() {
|
||||||
mobileMenuOpen.value = false;
|
mobileMenuOpen.value = false;
|
||||||
|
customizer.SET_CHAT_SIDEBAR(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步 nav header 中的 sidebar toggle
|
||||||
|
watch(() => customizer.chatSidebarOpen, (val) => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
mobileMenuOpen.value = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const customizer = useCustomizerStore();
|
|
||||||
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
|
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
|
||||||
customizer.SET_UI_THEME(newTheme);
|
customizer.SET_UI_THEME(newTheme);
|
||||||
theme.global.name.value = newTheme;
|
theme.global.name.value = newTheme;
|
||||||
@@ -722,6 +725,7 @@ onBeforeUnmount(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-page-container {
|
.chat-page-container {
|
||||||
|
|||||||
@@ -32,11 +32,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<textarea ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" :disabled="disabled"
|
<textarea ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" :disabled="disabled"
|
||||||
placeholder="Ask AstrBot..."
|
placeholder="Ask AstrBot..." class="chat-textarea"
|
||||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false"
|
||||||
|
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 16px 20px; min-height: 40px; max-height: 200px; overflow-y: auto; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
|
||||||
<div
|
<div
|
||||||
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden;">
|
||||||
<!-- Settings Menu -->
|
<!-- Settings Menu -->
|
||||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
@@ -72,9 +73,9 @@
|
|||||||
<!-- Provider/Model Selector Menu -->
|
<!-- Provider/Model Selector Menu -->
|
||||||
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center; flex-shrink: 0;">
|
||||||
<input type="file" ref="imageInputRef" @change="handleFileSelect" style="display: none" multiple />
|
<input type="file" ref="imageInputRef" @change="handleFileSelect" style="display: none" multiple />
|
||||||
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
<v-progress-circular v-if="disabled && !mobile" indeterminate size="16" class="mr-1" width="1.5" />
|
||||||
<!-- <v-btn @click="$emit('openLiveMode')"
|
<!-- <v-btn @click="$emit('openLiveMode')"
|
||||||
icon
|
icon
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -87,36 +88,21 @@
|
|||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn> -->
|
</v-btn> -->
|
||||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
||||||
class="record-btn" size="small">
|
class="record-btn">
|
||||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||||
plain></v-icon>
|
plain></v-icon>
|
||||||
<v-tooltip activator="parent" location="top">
|
<v-tooltip activator="parent" location="top">
|
||||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
||||||
icon
|
|
||||||
v-if="isRunning"
|
|
||||||
@click="$emit('stop')"
|
|
||||||
variant="text"
|
|
||||||
class="send-btn"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||||
<v-tooltip activator="parent" location="top">
|
<v-tooltip activator="parent" location="top">
|
||||||
{{ tm('input.stopGenerating') }}
|
{{ tm('input.stopGenerating') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
||||||
v-else
|
:disabled="!canSend" class="send-btn" />
|
||||||
@click="$emit('send')"
|
|
||||||
icon="mdi-send"
|
|
||||||
variant="text"
|
|
||||||
color="deep-purple"
|
|
||||||
:disabled="!canSend"
|
|
||||||
class="send-btn"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +138,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
import { useCustomizerStore } from '@/stores/customizer';
|
import { useCustomizerStore } from '@/stores/customizer';
|
||||||
import ConfigSelector from './ConfigSelector.vue';
|
import ConfigSelector from './ConfigSelector.vue';
|
||||||
@@ -251,21 +238,34 @@ function handleReplyAfterLeave() {
|
|||||||
isReplyClosing.value = false;
|
isReplyClosing.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
const { mobile } = useDisplay();
|
||||||
// Enter 发送消息或触发命令
|
|
||||||
if (e.keyCode === 13 && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// 检查是否是 /astr_live_dev 命令
|
// Auto-resize textarea
|
||||||
|
function autoResize() {
|
||||||
|
const el = inputField.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(localPrompt, () => {
|
||||||
|
nextTick(autoResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||||
|
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||||
|
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||||
emit('openLiveMode');
|
emit('openLiveMode');
|
||||||
localPrompt.value = '';
|
localPrompt.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canSend.value) {
|
if (canSend.value) {
|
||||||
emit('send');
|
emit('send');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+B 录音
|
// Ctrl+B 录音
|
||||||
@@ -588,11 +588,20 @@ defineExpose({
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.input-area {
|
.input-area {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
padding-bottom: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-container {
|
.input-container {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-area textarea,
|
||||||
|
.chat-textarea {
|
||||||
|
min-height: 32px !important;
|
||||||
|
max-height: 160px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 16px 16px 12px 16px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
@deleteProject="$emit('deleteProject', $event)"
|
@deleteProject="$emit('deleteProject', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style="overflow-y: auto; flex-grow: 1;"
|
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
|
||||||
v-if="!sidebarCollapsed || isMobile">
|
v-if="!sidebarCollapsed || isMobile">
|
||||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||||
<v-list density="compact" nav class="conversation-list"
|
<v-list density="compact" nav class="conversation-list"
|
||||||
@@ -326,6 +326,13 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.conversation-actions {
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.edit-title-btn,
|
.edit-title-btn,
|
||||||
.delete-conversation-btn {
|
.delete-conversation-btn {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -965,6 +965,7 @@ export default {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -370,7 +370,8 @@
|
|||||||
"hint": "Optional Discord activity name. Leave empty to disable."
|
"hint": "Optional Discord activity name. Leave empty to disable."
|
||||||
},
|
},
|
||||||
"discord_command_register": {
|
"discord_command_register": {
|
||||||
"description": "Auto-register plugin commands as Discord slash commands"
|
"description": "Register Discord slash commands",
|
||||||
|
"hint": "When enabled, AstrBot will automatically register plugin commands as Discord slash commands"
|
||||||
},
|
},
|
||||||
"discord_proxy": {
|
"discord_proxy": {
|
||||||
"description": "Discord Proxy URL",
|
"description": "Discord Proxy URL",
|
||||||
@@ -583,6 +584,51 @@
|
|||||||
"only_use_webhook_url_to_send": {
|
"only_use_webhook_url_to_send": {
|
||||||
"description": "Send Replies via Webhook Only",
|
"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. "
|
"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": {
|
"general": {
|
||||||
@@ -737,6 +783,17 @@
|
|||||||
"hint": "Telegram only supports a fixed reaction set, reference: [https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
|
"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., 👍, 🤔, ⏳"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,10 +224,43 @@
|
|||||||
"empty": "No Skills found",
|
"empty": "No Skills found",
|
||||||
"emptyHint": "Upload a Skills zip to get started",
|
"emptyHint": "Upload a Skills zip to get started",
|
||||||
"uploadDialogTitle": "Upload Skills",
|
"uploadDialogTitle": "Upload Skills",
|
||||||
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
|
"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.",
|
||||||
"selectFile": "Select file",
|
"selectFile": "Select file",
|
||||||
"confirmUpload": "Upload",
|
"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",
|
||||||
"cancel": "Cancel",
|
"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",
|
"noDescription": "No description",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"uploadSuccess": "Upload succeeded",
|
"uploadSuccess": "Upload succeeded",
|
||||||
|
|||||||
@@ -373,7 +373,8 @@
|
|||||||
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
|
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
|
||||||
},
|
},
|
||||||
"discord_command_register": {
|
"discord_command_register": {
|
||||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令"
|
"description": "注册 Discord 指令",
|
||||||
|
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令"
|
||||||
},
|
},
|
||||||
"discord_proxy": {
|
"discord_proxy": {
|
||||||
"description": "Discord 代理地址",
|
"description": "Discord 代理地址",
|
||||||
@@ -586,6 +587,51 @@
|
|||||||
"only_use_webhook_url_to_send": {
|
"only_use_webhook_url_to_send": {
|
||||||
"description": "仅使用 Webhook 发送消息",
|
"description": "仅使用 Webhook 发送消息",
|
||||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 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": "允许的最大连续失败次数,超过后停止重试"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
@@ -740,6 +786,17 @@
|
|||||||
"hint": "Telegram 仅支持固定反应集合,参考:[https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
|
"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 表情符号,例如:👍、🤔、⏳"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,10 +224,43 @@
|
|||||||
"empty": "暂无 Skills",
|
"empty": "暂无 Skills",
|
||||||
"emptyHint": "请上传 Skills 压缩包",
|
"emptyHint": "请上传 Skills 压缩包",
|
||||||
"uploadDialogTitle": "上传 Skills",
|
"uploadDialogTitle": "上传 Skills",
|
||||||
"uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
|
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
|
||||||
|
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
|
||||||
|
"abilityMultiple": "支持一次上传多个zip文件",
|
||||||
|
"abilityValidate": "自动校验 `SKILL.md`",
|
||||||
|
"abilitySkip": "自动跳过重复文件",
|
||||||
"selectFile": "选择文件",
|
"selectFile": "选择文件",
|
||||||
"confirmUpload": "上传",
|
"selectFiles": "选择文件(可多选)",
|
||||||
|
"dropzoneTitle": "拖拽多个 zip 文件到这里",
|
||||||
|
"dropzoneAction": "或者点击之后在文件夹中选择多个文件",
|
||||||
|
"dropzoneHint": "支持批量上传,系统会自动校验目录结构",
|
||||||
|
"fileListTitle": "待处理文件",
|
||||||
|
"fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
|
||||||
|
"uploading": "正在上传...",
|
||||||
|
"batchResultTitle": "批量上传结果",
|
||||||
|
"batchResultSummary": "共 {total} 个文件,成功 {success} 个",
|
||||||
|
"batchSuccessList": "上传成功",
|
||||||
|
"batchFailedList": "上传失败",
|
||||||
|
"confirm": "确定",
|
||||||
|
"confirmUpload": "开始上传",
|
||||||
"cancel": "取消",
|
"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": "无描述",
|
"noDescription": "无描述",
|
||||||
"path": "路径",
|
"path": "路径",
|
||||||
"uploadSuccess": "上传成功",
|
"uploadSuccess": "上传成功",
|
||||||
|
|||||||
@@ -468,6 +468,12 @@ onMounted(async () => {
|
|||||||
<v-icon>mdi-menu</v-icon>
|
<v-icon>mdi-menu</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
|
||||||
|
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
|
||||||
|
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
|
||||||
|
<v-icon>mdi-menu</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
|
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
|
||||||
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
|
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
|
||||||
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
|
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
|
||||||
@@ -488,13 +494,13 @@ onMounted(async () => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bot/Chat 模式切换按钮 -->
|
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏,移入 ... 菜单 -->
|
||||||
<v-btn-toggle
|
<v-btn-toggle
|
||||||
v-model="viewMode"
|
v-model="viewMode"
|
||||||
mandatory
|
mandatory
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
class="mr-4"
|
class="mr-4 hidden-xs"
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
<v-btn value="bot" size="small">
|
<v-btn value="bot" size="small">
|
||||||
@@ -524,6 +530,30 @@ onMounted(async () => {
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
|
||||||
|
<template v-if="$vuetify.display.xs">
|
||||||
|
<div class="mobile-mode-toggle-wrapper">
|
||||||
|
<v-btn-toggle
|
||||||
|
v-model="viewMode"
|
||||||
|
mandatory
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
class="mobile-mode-toggle"
|
||||||
|
>
|
||||||
|
<v-btn value="bot" size="small">
|
||||||
|
<v-icon start>mdi-robot</v-icon>
|
||||||
|
Bot
|
||||||
|
</v-btn>
|
||||||
|
<v-btn value="chat" size="small">
|
||||||
|
<v-icon start>mdi-chat</v-icon>
|
||||||
|
Chat
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</div>
|
||||||
|
<v-divider class="my-1" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
<!-- 语言切换 -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="lang in languages"
|
v-for="lang in languages"
|
||||||
@@ -888,6 +918,10 @@ onMounted(async () => {
|
|||||||
margin-left: 22px;
|
margin-left: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-logo.chat-mode-logo {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 1000;
|
font-weight: 1000;
|
||||||
@@ -926,6 +960,20 @@ onMounted(async () => {
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-mode-toggle-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mode-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mode-toggle .v-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* 移动端对话框标题样式 */
|
/* 移动端对话框标题样式 */
|
||||||
.mobile-card-title {
|
.mobile-card-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const isItemActive = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- children -->
|
<!-- children -->
|
||||||
<template v-for="(child, index) in item.children" :key="index">
|
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
|
||||||
<NavItem :item="child" :level="(level || 0) + 1" />
|
<NavItem :item="child" :level="(level || 0) + 1" />
|
||||||
</template>
|
</template>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|||||||
@@ -10,26 +10,60 @@ import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const customizer = useCustomizerStore();
|
const customizer = useCustomizerStore();
|
||||||
const sidebarMenu = shallowRef(sidebarItems);
|
|
||||||
|
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 openedItems = ref(JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]'));
|
const openedItems = ref(getInitialOpenedItems(sidebarMenu.value));
|
||||||
watch(openedItems, (val) => localStorage.setItem('sidebar_openedItems', JSON.stringify(val)), { deep: true });
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply customization on mount and listen for storage changes
|
// Apply customization on mount and listen for storage changes
|
||||||
const handleStorageChange = (e) => {
|
const handleStorageChange = (e) => {
|
||||||
if (e.key === 'astrbot_sidebar_customization') {
|
if (e.key === 'astrbot_sidebar_customization') {
|
||||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
refreshSidebarMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomEvent = () => {
|
const handleCustomEvent = () => {
|
||||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
refreshSidebarMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorageChange);
|
window.addEventListener('storage', handleStorageChange);
|
||||||
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
|
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
|
||||||
});
|
});
|
||||||
@@ -255,7 +289,7 @@ function openChangelogDialog() {
|
|||||||
>
|
>
|
||||||
<div class="sidebar-container">
|
<div class="sidebar-container">
|
||||||
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
|
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
|
||||||
<template v-for="(item, i) in sidebarMenu" :key="i">
|
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
|
||||||
<NavItem :item="item" class="leftPadding" />
|
<NavItem :item="item" class="leftPadding" />
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|||||||
@@ -15,3 +15,7 @@
|
|||||||
@import './components/VScrollbar';
|
@import './components/VScrollbar';
|
||||||
|
|
||||||
@import './pages/dashboards';
|
@import './pages/dashboards';
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export const useCustomizerStore = defineStore({
|
|||||||
fontTheme: "Poppins",
|
fontTheme: "Poppins",
|
||||||
uiTheme: config.uiTheme,
|
uiTheme: config.uiTheme,
|
||||||
inputBg: config.inputBg,
|
inputBg: config.inputBg,
|
||||||
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot' // 'bot' 或 'chat'
|
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
|
||||||
|
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {},
|
getters: {},
|
||||||
@@ -30,7 +31,13 @@ export const useCustomizerStore = defineStore({
|
|||||||
},
|
},
|
||||||
SET_VIEW_MODE(payload: 'bot' | 'chat') {
|
SET_VIEW_MODE(payload: 'bot' | 'chat') {
|
||||||
this.viewMode = payload;
|
this.viewMode = payload;
|
||||||
localStorage.setItem("viewMode", payload);
|
localStorage.setItem('viewMode', payload);
|
||||||
|
},
|
||||||
|
TOGGLE_CHAT_SIDEBAR() {
|
||||||
|
this.chatSidebarOpen = !this.chatSidebarOpen;
|
||||||
|
},
|
||||||
|
SET_CHAT_SIDEBAR(payload: boolean) {
|
||||||
|
this.chatSidebarOpen = payload;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,22 +46,22 @@ export function getPlatformIcon(name) {
|
|||||||
*/
|
*/
|
||||||
export function getTutorialLink(platformType) {
|
export function getTutorialLink(platformType) {
|
||||||
const tutorialMap = {
|
const tutorialMap = {
|
||||||
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html",
|
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
|
||||||
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html",
|
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
|
||||||
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
|
||||||
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html",
|
"wecom": "https://docs.astrbot.app/platform/wecom.html",
|
||||||
"wecom_ai_bot": "https://docs.astrbot.app/deploy/platform/wecom_ai_bot.html",
|
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",
|
||||||
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
|
"lark": "https://docs.astrbot.app/platform/lark.html",
|
||||||
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
|
"telegram": "https://docs.astrbot.app/platform/telegram.html",
|
||||||
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
|
"dingtalk": "https://docs.astrbot.app/platform/dingtalk.html",
|
||||||
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
|
"weixin_official_account": "https://docs.astrbot.app/platform/weixin-official-account.html",
|
||||||
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
|
"discord": "https://docs.astrbot.app/platform/discord.html",
|
||||||
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
|
"slack": "https://docs.astrbot.app/platform/slack.html",
|
||||||
"kook": "https://docs.astrbot.app/deploy/platform/kook.html",
|
"kook": "https://docs.astrbot.app/platform/kook.html",
|
||||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
"vocechat": "https://docs.astrbot.app/platform/vocechat.html",
|
||||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
"satori": "https://docs.astrbot.app/platform/satori/llonebot.html",
|
||||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
"misskey": "https://docs.astrbot.app/platform/misskey.html",
|
||||||
"line": "https://docs.astrbot.app/deploy/platform/line.html",
|
"line": "https://docs.astrbot.app/platform/line.html",
|
||||||
}
|
}
|
||||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { pinyin } from "pinyin-pro";
|
||||||
|
|
||||||
|
const HAN_IDEOGRAPH_RE = /\p{Unified_Ideograph}/u;
|
||||||
|
|
||||||
|
export const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
|
||||||
|
|
||||||
|
const normalizeLooseFromNormalized = (normalized) =>
|
||||||
|
normalized.replace(/[\s_-]+/g, "").replace(/[()()【】\[\]{}·•]+/g, "");
|
||||||
|
|
||||||
|
export const normalizeLoose = (s) =>
|
||||||
|
normalizeLooseFromNormalized(normalizeStr(s));
|
||||||
|
|
||||||
|
const memoizeStringFn = (fn) => {
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
return (raw) => {
|
||||||
|
const key = (raw ?? "").toString();
|
||||||
|
if (cache.has(key)) {
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = fn(key);
|
||||||
|
cache.set(key, value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormalizedText = memoizeStringFn(normalizeStr);
|
||||||
|
|
||||||
|
const getLooseText = memoizeStringFn((text) =>
|
||||||
|
normalizeLooseFromNormalized(getNormalizedText(text)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const toPinyinText = memoizeStringFn((text) =>
|
||||||
|
pinyin(text, { toneType: "none" })
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const toInitials = memoizeStringFn((text) =>
|
||||||
|
pinyin(text, { pattern: "first", toneType: "none" })
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const buildSearchQuery = (raw) => {
|
||||||
|
const norm = getNormalizedText(raw);
|
||||||
|
if (!norm) return null;
|
||||||
|
return {
|
||||||
|
norm,
|
||||||
|
loose: getLooseText(raw),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchesText = (value, query) => {
|
||||||
|
if (value == null || !query?.norm) return false;
|
||||||
|
const text = String(value);
|
||||||
|
|
||||||
|
const normalizedValue = getNormalizedText(text);
|
||||||
|
const looseValue = query.loose ? getLooseText(text) : null;
|
||||||
|
|
||||||
|
if (normalizedValue.includes(query.norm)) return true;
|
||||||
|
if (query.loose && looseValue?.includes(query.loose)) return true;
|
||||||
|
|
||||||
|
if (!HAN_IDEOGRAPH_RE.test(text)) return false;
|
||||||
|
|
||||||
|
const pinyinValue = toPinyinText(text);
|
||||||
|
if (pinyinValue.includes(query.norm)) return true;
|
||||||
|
|
||||||
|
const initialsValue = toInitials(text);
|
||||||
|
if (initialsValue.includes(query.norm)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPluginSearchFields = (plugin) => {
|
||||||
|
const supportPlatforms = Array.isArray(plugin?.support_platforms)
|
||||||
|
? plugin.support_platforms.join(" ")
|
||||||
|
: "";
|
||||||
|
const tags = Array.isArray(plugin?.tags) ? plugin.tags.join(" ") : "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
plugin?.name,
|
||||||
|
plugin?.trimmedName,
|
||||||
|
plugin?.display_name,
|
||||||
|
plugin?.desc,
|
||||||
|
plugin?.author,
|
||||||
|
plugin?.repo,
|
||||||
|
plugin?.version,
|
||||||
|
plugin?.astrbot_version,
|
||||||
|
supportPlatforms,
|
||||||
|
tags,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchesPluginSearch = (plugin, query) => {
|
||||||
|
if (!query) return true;
|
||||||
|
|
||||||
|
return getPluginSearchFields(plugin).some((candidate) =>
|
||||||
|
matchesText(candidate, query),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,6 +52,21 @@ export function clearSidebarCustomization() {
|
|||||||
export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
||||||
const { cloneItems = false, assembleMoreGroup = false } = options;
|
const { cloneItems = false, assembleMoreGroup = false } = options;
|
||||||
|
|
||||||
|
const normalizeKeys = (keys = []) => {
|
||||||
|
const list = Array.isArray(keys) ? keys : [];
|
||||||
|
const deduped = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
list.forEach((key) => {
|
||||||
|
if (typeof key !== 'string') return;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
};
|
||||||
|
|
||||||
const all = new Map();
|
const all = new Map();
|
||||||
const defaultMain = [];
|
const defaultMain = [];
|
||||||
const defaultMore = [];
|
const defaultMore = [];
|
||||||
@@ -70,9 +85,23 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasCustomization = Boolean(customization);
|
const hasCustomization = Boolean(customization);
|
||||||
const mainKeys = hasCustomization ? customization.mainItems || [] : defaultMain;
|
let mainKeys = hasCustomization ? normalizeKeys(customization.mainItems || []) : [...defaultMain];
|
||||||
const moreKeys = hasCustomization ? customization.moreItems || [] : defaultMore;
|
let moreKeys = hasCustomization ? normalizeKeys(customization.moreItems || []) : [...defaultMore];
|
||||||
const used = hasCustomization ? new Set([...mainKeys, ...moreKeys]) : new Set(defaultMain.concat(defaultMore));
|
|
||||||
|
if (hasCustomization) {
|
||||||
|
mainKeys = mainKeys.filter(title => all.has(title));
|
||||||
|
moreKeys = moreKeys.filter(title => all.has(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCustomization) {
|
||||||
|
// 如果同一项同时出现在主区与更多区,主区优先。
|
||||||
|
const mainSet = new Set(mainKeys);
|
||||||
|
moreKeys = moreKeys.filter(title => !mainSet.has(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = hasCustomization
|
||||||
|
? new Set([...mainKeys, ...moreKeys])
|
||||||
|
: new Set(defaultMain.concat(defaultMore));
|
||||||
|
|
||||||
const mainItems = mainKeys
|
const mainItems = mainKeys
|
||||||
.map(title => all.get(title))
|
.map(title => all.get(title))
|
||||||
@@ -119,7 +148,13 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { mainItems, moreItems, merged };
|
return {
|
||||||
|
mainItems,
|
||||||
|
moreItems,
|
||||||
|
merged,
|
||||||
|
normalizedMainKeys: [...mainKeys],
|
||||||
|
normalizedMoreKeys: [...moreKeys]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,9 +164,29 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
|||||||
*/
|
*/
|
||||||
export function applySidebarCustomization(defaultItems) {
|
export function applySidebarCustomization(defaultItems) {
|
||||||
const customization = getSidebarCustomization();
|
const customization = getSidebarCustomization();
|
||||||
const { merged } = resolveSidebarItems(defaultItems, customization, {
|
const {
|
||||||
|
merged,
|
||||||
|
normalizedMainKeys,
|
||||||
|
normalizedMoreKeys
|
||||||
|
} = resolveSidebarItems(defaultItems, customization, {
|
||||||
cloneItems: true,
|
cloneItems: true,
|
||||||
assembleMoreGroup: true
|
assembleMoreGroup: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (customization) {
|
||||||
|
const rawMainKeys = Array.isArray(customization.mainItems) ? customization.mainItems : [];
|
||||||
|
const rawMoreKeys = Array.isArray(customization.moreItems) ? customization.moreItems : [];
|
||||||
|
const hasChanged =
|
||||||
|
JSON.stringify(rawMainKeys) !== JSON.stringify(normalizedMainKeys) ||
|
||||||
|
JSON.stringify(rawMoreKeys) !== JSON.stringify(normalizedMoreKeys);
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
setSidebarCustomization({
|
||||||
|
mainItems: normalizedMainKeys,
|
||||||
|
moreItems: normalizedMoreKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return merged || defaultItems;
|
return merged || defaultItems;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ const {
|
|||||||
normalizeStr,
|
normalizeStr,
|
||||||
toPinyinText,
|
toPinyinText,
|
||||||
toInitials,
|
toInitials,
|
||||||
marketCustomFilter,
|
|
||||||
plugin_handler_info_headers,
|
plugin_handler_info_headers,
|
||||||
pluginHeaders,
|
pluginHeaders,
|
||||||
filteredExtensions,
|
filteredExtensions,
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ const {
|
|||||||
normalizeStr,
|
normalizeStr,
|
||||||
toPinyinText,
|
toPinyinText,
|
||||||
toInitials,
|
toInitials,
|
||||||
marketCustomFilter,
|
|
||||||
plugin_handler_info_headers,
|
plugin_handler_info_headers,
|
||||||
pluginHeaders,
|
pluginHeaders,
|
||||||
filteredExtensions,
|
filteredExtensions,
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ const {
|
|||||||
normalizeStr,
|
normalizeStr,
|
||||||
toPinyinText,
|
toPinyinText,
|
||||||
toInitials,
|
toInitials,
|
||||||
marketCustomFilter,
|
|
||||||
plugin_handler_info_headers,
|
plugin_handler_info_headers,
|
||||||
pluginHeaders,
|
pluginHeaders,
|
||||||
filteredExtensions,
|
filteredExtensions,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { pinyin } from "pinyin-pro";
|
|
||||||
import { useCommonStore } from "@/stores/common";
|
import { useCommonStore } from "@/stores/common";
|
||||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||||
import { resolveErrorMessage } from "@/utils/errorUtils";
|
import { resolveErrorMessage } from "@/utils/errorUtils";
|
||||||
|
import {
|
||||||
|
buildSearchQuery,
|
||||||
|
matchesPluginSearch,
|
||||||
|
normalizeStr,
|
||||||
|
toInitials,
|
||||||
|
toPinyinText,
|
||||||
|
} from "@/utils/pluginSearch";
|
||||||
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
@@ -240,37 +246,6 @@ export const useExtensionPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 插件市场拼音搜索
|
// 插件市场拼音搜索
|
||||||
const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
|
|
||||||
const toPinyinText = (s) =>
|
|
||||||
pinyin(s ?? "", { toneType: "none" })
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, "");
|
|
||||||
const toInitials = (s) =>
|
|
||||||
pinyin(s ?? "", { pattern: "first", toneType: "none" })
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, "");
|
|
||||||
const marketCustomFilter = (value, query, item) => {
|
|
||||||
const q = normalizeStr(query);
|
|
||||||
if (!q) return true;
|
|
||||||
|
|
||||||
const candidates = new Set();
|
|
||||||
if (value != null) candidates.add(String(value));
|
|
||||||
if (item?.name) candidates.add(String(item.name));
|
|
||||||
if (item?.trimmedName) candidates.add(String(item.trimmedName));
|
|
||||||
if (item?.display_name) candidates.add(String(item.display_name));
|
|
||||||
if (item?.desc) candidates.add(String(item.desc));
|
|
||||||
if (item?.author) candidates.add(String(item.author));
|
|
||||||
|
|
||||||
for (const v of candidates) {
|
|
||||||
const nv = normalizeStr(v);
|
|
||||||
if (nv.includes(q)) return true;
|
|
||||||
const pv = toPinyinText(v);
|
|
||||||
if (pv.includes(q)) return true;
|
|
||||||
const iv = toInitials(v);
|
|
||||||
if (iv.includes(q)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const plugin_handler_info_headers = computed(() => [
|
const plugin_handler_info_headers = computed(() => [
|
||||||
{ title: tm("table.headers.eventType"), key: "event_type_h" },
|
{ title: tm("table.headers.eventType"), key: "event_type_h" },
|
||||||
@@ -347,47 +322,24 @@ export const useExtensionPage = () => {
|
|||||||
// 通过搜索过滤插件
|
// 通过搜索过滤插件
|
||||||
const filteredPlugins = computed(() => {
|
const filteredPlugins = computed(() => {
|
||||||
const plugins = filteredExtensions.value;
|
const plugins = filteredExtensions.value;
|
||||||
let filtered = plugins;
|
const query = buildSearchQuery(pluginSearch.value);
|
||||||
|
const filtered = query
|
||||||
if (pluginSearch.value) {
|
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
|
||||||
const search = pluginSearch.value.toLowerCase();
|
: plugins;
|
||||||
filtered = plugins.filter((plugin) => {
|
|
||||||
const pluginName = (plugin.name ?? "").toLowerCase();
|
|
||||||
const pluginDesc = (plugin.desc ?? "").toLowerCase();
|
|
||||||
const pluginAuthor = (plugin.author ?? "").toLowerCase();
|
|
||||||
const supportPlatforms = Array.isArray(plugin.support_platforms)
|
|
||||||
? plugin.support_platforms.join(" ").toLowerCase()
|
|
||||||
: "";
|
|
||||||
const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
pluginName.includes(search) ||
|
|
||||||
pluginDesc.includes(search) ||
|
|
||||||
pluginAuthor.includes(search) ||
|
|
||||||
supportPlatforms.includes(search) ||
|
|
||||||
astrbotVersion.includes(search)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortPluginsByName([...filtered]);
|
return sortPluginsByName([...filtered]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 过滤后的插件市场数据(带搜索)
|
// 过滤后的插件市场数据(带搜索)
|
||||||
const filteredMarketPlugins = computed(() => {
|
const filteredMarketPlugins = computed(() => {
|
||||||
if (!debouncedMarketSearch.value) {
|
const query = buildSearchQuery(debouncedMarketSearch.value);
|
||||||
|
if (!query) {
|
||||||
return pluginMarketData.value;
|
return pluginMarketData.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = debouncedMarketSearch.value.toLowerCase();
|
return pluginMarketData.value.filter((plugin) =>
|
||||||
return pluginMarketData.value.filter((plugin) => {
|
matchesPluginSearch(plugin, query),
|
||||||
// 使用自定义过滤器
|
);
|
||||||
return (
|
|
||||||
marketCustomFilter(plugin.name, search, plugin) ||
|
|
||||||
marketCustomFilter(plugin.desc, search, plugin) ||
|
|
||||||
marketCustomFilter(plugin.author, search, plugin)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 所有插件列表,推荐插件排在前面
|
// 所有插件列表,推荐插件排在前面
|
||||||
@@ -1563,7 +1515,6 @@ export const useExtensionPage = () => {
|
|||||||
normalizeStr,
|
normalizeStr,
|
||||||
toPinyinText,
|
toPinyinText,
|
||||||
toInitials,
|
toInitials,
|
||||||
marketCustomFilter,
|
|
||||||
plugin_handler_info_headers,
|
plugin_handler_info_headers,
|
||||||
pluginHeaders,
|
pluginHeaders,
|
||||||
filteredExtensions,
|
filteredExtensions,
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "AstrBot"
|
name = "AstrBot"
|
||||||
version = "4.18.3"
|
version = "4.19.2"
|
||||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -39,7 +39,7 @@ dependencies = [
|
|||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pydub>=0.25.1",
|
"pydub>=0.25.1",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.6",
|
||||||
"qq-botpy>=1.2.1",
|
"qq-botpy>=1.2.1",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"readability-lxml>=0.8.4.1",
|
"readability-lxml>=0.8.4.1",
|
||||||
@@ -61,7 +61,7 @@ dependencies = [
|
|||||||
"xinference-client",
|
"xinference-client",
|
||||||
"tenacity>=9.1.2",
|
"tenacity>=9.1.2",
|
||||||
"shipyard-python-sdk>=0.2.4",
|
"shipyard-python-sdk>=0.2.4",
|
||||||
"shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk",
|
"shipyard-neo-sdk>=0.2.0",
|
||||||
"python-socks>=2.8.0",
|
"python-socks>=2.8.0",
|
||||||
"packaging>=24.2",
|
"packaging>=24.2",
|
||||||
]
|
]
|
||||||
@@ -114,6 +114,14 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
|
|||||||
[tool.hatch.metadata]
|
[tool.hatch.metadata]
|
||||||
allow-direct-references = true
|
allow-direct-references = true
|
||||||
|
|
||||||
|
# Include bundled dashboard dist even though it is not tracked by VCS.
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
artifacts = ["astrbot/dashboard/dist/**"]
|
||||||
|
|
||||||
|
# Custom build hook: builds the Vue dashboard and copies dist into the package.
|
||||||
|
[tool.hatch.build.hooks.custom]
|
||||||
|
path = "scripts/hatch_build.py"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ py-cord>=2.6.1
|
|||||||
pydantic>=2.12.5
|
pydantic>=2.12.5
|
||||||
pydub>=0.25.1
|
pydub>=0.25.1
|
||||||
pyjwt>=2.10.1
|
pyjwt>=2.10.1
|
||||||
python-telegram-bot>=22.0
|
python-telegram-bot>=22.6
|
||||||
qq-botpy>=1.2.1
|
qq-botpy>=1.2.1
|
||||||
quart>=0.20.0
|
quart>=0.20.0
|
||||||
readability-lxml>=0.8.4.1
|
readability-lxml>=0.8.4.1
|
||||||
@@ -54,5 +54,5 @@ markitdown-no-magika[docx,xls,xlsx]>=0.1.2
|
|||||||
xinference-client
|
xinference-client
|
||||||
tenacity>=9.1.2
|
tenacity>=9.1.2
|
||||||
shipyard-python-sdk>=0.2.4
|
shipyard-python-sdk>=0.2.4
|
||||||
shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk
|
shipyard-neo-sdk>=0.2.0
|
||||||
packaging>=24.2
|
packaging>=24.2
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Custom Hatchling build hook.
|
||||||
|
|
||||||
|
Only runs when the environment variable ASTRBOT_BUILD_DASHBOARD=1 is set,
|
||||||
|
so that `uv sync` / editable installs are never affected.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ASTRBOT_BUILD_DASHBOARD=1 uv build
|
||||||
|
|
||||||
|
When enabled, this hook:
|
||||||
|
1. Runs `npm run build` inside the `dashboard/` directory.
|
||||||
|
2. Copies the resulting `dashboard/dist/` tree into
|
||||||
|
`astrbot/dashboard/dist/` so the static assets are shipped
|
||||||
|
inside the Python wheel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface):
|
||||||
|
PLUGIN_NAME = "custom"
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None:
|
||||||
|
# Only run when explicitly requested (e.g. during CI / release builds).
|
||||||
|
# This prevents `uv sync` / editable installs from triggering npm.
|
||||||
|
if os.environ.get("ASTRBOT_BUILD_DASHBOARD", "").strip() != "1":
|
||||||
|
return
|
||||||
|
|
||||||
|
root = Path(self.root)
|
||||||
|
dashboard_src = root / "dashboard"
|
||||||
|
dist_src = dashboard_src / "dist"
|
||||||
|
dist_target = root / "astrbot" / "dashboard" / "dist"
|
||||||
|
|
||||||
|
if not dashboard_src.exists():
|
||||||
|
print(
|
||||||
|
"[hatch_build] 'dashboard/' directory not found – skipping dashboard build.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Install Node dependencies if node_modules is absent ─────────────
|
||||||
|
if not (dashboard_src / "node_modules").exists():
|
||||||
|
print("[hatch_build] Installing dashboard Node dependencies...")
|
||||||
|
subprocess.run(
|
||||||
|
["npm", "install"],
|
||||||
|
cwd=dashboard_src,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Build the Vue/Vite dashboard ──────────────────────────────────────
|
||||||
|
print("[hatch_build] Building Vue dashboard (npm run build)...")
|
||||||
|
subprocess.run(
|
||||||
|
["npm", "run", "build"],
|
||||||
|
cwd=dashboard_src,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dist_src.exists():
|
||||||
|
print(
|
||||||
|
"[hatch_build] dashboard/dist not found after build – skipping copy.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Copy into the Python package tree ────────────────────────────────
|
||||||
|
if dist_target.exists():
|
||||||
|
shutil.rmtree(dist_target)
|
||||||
|
shutil.copytree(dist_src, dist_target)
|
||||||
|
print(f"[hatch_build] Dashboard dist copied → {dist_target.relative_to(root)}")
|
||||||
@@ -52,18 +52,6 @@ class TestContextTruncator:
|
|||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
assert result == messages
|
assert result == messages
|
||||||
|
|
||||||
def test_fix_messages_tool_with_valid_context(self):
|
|
||||||
"""Test fix_messages with tool message after user+assistant."""
|
|
||||||
truncator = ContextTruncator()
|
|
||||||
messages = [
|
|
||||||
self.create_message("user", "Run tool"),
|
|
||||||
self.create_message("assistant", "Running..."),
|
|
||||||
self.create_message("tool", "Tool result"),
|
|
||||||
]
|
|
||||||
result = truncator.fix_messages(messages)
|
|
||||||
assert len(result) == 3
|
|
||||||
assert result == messages
|
|
||||||
|
|
||||||
def test_fix_messages_tool_without_context(self):
|
def test_fix_messages_tool_without_context(self):
|
||||||
"""Test fix_messages with tool message without enough context."""
|
"""Test fix_messages with tool message without enough context."""
|
||||||
truncator = ContextTruncator()
|
truncator = ContextTruncator()
|
||||||
@@ -74,43 +62,6 @@ class TestContextTruncator:
|
|||||||
# Tool message without context should be removed
|
# Tool message without context should be removed
|
||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
def test_fix_messages_tool_with_only_one_message(self):
|
|
||||||
"""Test fix_messages with tool message after only one message."""
|
|
||||||
truncator = ContextTruncator()
|
|
||||||
messages = [
|
|
||||||
self.create_message("user", "Hello"),
|
|
||||||
self.create_message("tool", "Tool result"),
|
|
||||||
]
|
|
||||||
result = truncator.fix_messages(messages)
|
|
||||||
# Tool message without enough context should be removed
|
|
||||||
assert len(result) == 0
|
|
||||||
|
|
||||||
def test_fix_messages_multiple_tools(self):
|
|
||||||
"""Test fix_messages with multiple tool messages."""
|
|
||||||
truncator = ContextTruncator()
|
|
||||||
messages = [
|
|
||||||
self.create_message("user", "Run tool"),
|
|
||||||
self.create_message("assistant", "Running..."),
|
|
||||||
self.create_message("tool", "Tool 1 result"),
|
|
||||||
self.create_message("tool", "Tool 2 result"),
|
|
||||||
]
|
|
||||||
result = truncator.fix_messages(messages)
|
|
||||||
assert len(result) == 4
|
|
||||||
assert result == messages
|
|
||||||
|
|
||||||
def test_fix_messages_mixed_system_tool(self):
|
|
||||||
"""Test fix_messages with system message and tool messages."""
|
|
||||||
truncator = ContextTruncator()
|
|
||||||
messages = [
|
|
||||||
self.create_message("system", "System prompt"),
|
|
||||||
self.create_message("user", "Run tool"),
|
|
||||||
self.create_message("assistant", "Running..."),
|
|
||||||
self.create_message("tool", "Tool result"),
|
|
||||||
]
|
|
||||||
result = truncator.fix_messages(messages)
|
|
||||||
assert len(result) == 4
|
|
||||||
assert result == messages
|
|
||||||
|
|
||||||
# ==================== truncate_by_turns Tests ====================
|
# ==================== truncate_by_turns Tests ====================
|
||||||
|
|
||||||
def test_truncate_by_turns_no_limit(self):
|
def test_truncate_by_turns_no_limit(self):
|
||||||
|
|||||||
Vendored
+1
@@ -110,6 +110,7 @@ class MockTelegramBuilder:
|
|||||||
bot.set_my_commands = AsyncMock()
|
bot.set_my_commands = AsyncMock()
|
||||||
bot.set_message_reaction = AsyncMock()
|
bot.set_message_reaction = AsyncMock()
|
||||||
bot.edit_message_text = AsyncMock()
|
bot.edit_message_text = AsyncMock()
|
||||||
|
bot.send_message_draft = AsyncMock()
|
||||||
return bot
|
return bot
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+227
-5
@@ -1,11 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import zipfile
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
from astrbot.core import LogBroker
|
from astrbot.core import LogBroker
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
@@ -15,7 +18,6 @@ from astrbot.core.star.star_handler import star_handlers_registry
|
|||||||
from astrbot.dashboard.server import AstrBotDashboard
|
from astrbot.dashboard.server import AstrBotDashboard
|
||||||
from tests.fixtures.helpers import (
|
from tests.fixtures.helpers import (
|
||||||
MockPluginBuilder,
|
MockPluginBuilder,
|
||||||
MockPluginConfig,
|
|
||||||
create_mock_updater_install,
|
create_mock_updater_install,
|
||||||
create_mock_updater_update,
|
create_mock_updater_update,
|
||||||
)
|
)
|
||||||
@@ -145,9 +147,7 @@ async def test_plugins(
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
core_lifecycle_td.plugin_manager.updator, "install", mock_install
|
core_lifecycle_td.plugin_manager.updator, "install", mock_install
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(core_lifecycle_td.plugin_manager.updator, "update", mock_update)
|
||||||
core_lifecycle_td.plugin_manager.updator, "update", mock_update
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 插件安装
|
# 插件安装
|
||||||
@@ -158,7 +158,9 @@ async def test_plugins(
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data["status"] == "ok", f"安装失败: {data.get('message', 'unknown error')}"
|
assert data["status"] == "ok", (
|
||||||
|
f"安装失败: {data.get('message', 'unknown error')}"
|
||||||
|
)
|
||||||
|
|
||||||
# 验证插件已注册
|
# 验证插件已注册
|
||||||
exists = any(md.name == test_plugin_name for md in star_registry)
|
exists = any(md.name == test_plugin_name for md in star_registry)
|
||||||
@@ -493,3 +495,223 @@ async def test_neo_skills_routes(
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
assert data["data"]["skill_key"] == "neo.demo"
|
assert data["data"]["skill_key"] == "neo.demo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_upload_skills_returns_error_when_all_files_invalid(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/skills/batch-upload",
|
||||||
|
headers=authenticated_header,
|
||||||
|
files={
|
||||||
|
"files": FileStorage(
|
||||||
|
stream=io.BytesIO(b"not-a-zip"),
|
||||||
|
filename="invalid.txt",
|
||||||
|
content_type="text/plain",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert data["message"] == "Upload failed for all 1 file(s)."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_upload_skills_accepts_zip_files(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
async def _fake_sync_skills_to_active_sandboxes():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _fake_install_skill_from_zip(
|
||||||
|
self,
|
||||||
|
zip_path: str,
|
||||||
|
*,
|
||||||
|
overwrite: bool = True,
|
||||||
|
):
|
||||||
|
_ = self, overwrite
|
||||||
|
assert zip_path.endswith(".zip")
|
||||||
|
return "demo_skill"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
|
||||||
|
_fake_sync_skills_to_active_sandboxes,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
|
||||||
|
_fake_install_skill_from_zip,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/skills/batch-upload",
|
||||||
|
headers=authenticated_header,
|
||||||
|
files={
|
||||||
|
"files": FileStorage(
|
||||||
|
stream=io.BytesIO(b"fake-zip"),
|
||||||
|
filename="demo_skill.zip",
|
||||||
|
content_type="application/zip",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["message"] == "All 1 skill(s) uploaded successfully."
|
||||||
|
assert data["data"]["total"] == 1
|
||||||
|
assert data["data"]["succeeded"] == [
|
||||||
|
{"filename": "demo_skill.zip", "name": "demo_skill"}
|
||||||
|
]
|
||||||
|
assert data["data"]["failed"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_upload_skills_accepts_valid_skill_archive(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
temp_dir = tmp_path / "temp"
|
||||||
|
data_dir.mkdir()
|
||||||
|
skills_dir.mkdir()
|
||||||
|
temp_dir.mkdir()
|
||||||
|
|
||||||
|
async def _fake_sync_skills_to_active_sandboxes():
|
||||||
|
return
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
|
||||||
|
_fake_sync_skills_to_active_sandboxes,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
|
||||||
|
lambda: str(data_dir),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
|
||||||
|
lambda: str(skills_dir),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
|
||||||
|
lambda: str(temp_dir),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.get_astrbot_temp_path",
|
||||||
|
lambda: str(temp_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
archive = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(
|
||||||
|
"demo_skill/SKILL.md",
|
||||||
|
"---\nname: demo-skill\ndescription: Demo skill\n---\n",
|
||||||
|
)
|
||||||
|
zf.writestr("demo_skill/notes.txt", "hello")
|
||||||
|
zf.writestr("__MACOSX/demo_skill/._SKILL.md", "")
|
||||||
|
zf.writestr("__MACOSX/._demo_skill", "")
|
||||||
|
archive.seek(0)
|
||||||
|
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/skills/batch-upload",
|
||||||
|
headers=authenticated_header,
|
||||||
|
files={
|
||||||
|
"files": FileStorage(
|
||||||
|
stream=archive,
|
||||||
|
filename="demo_skill.zip",
|
||||||
|
content_type="application/zip",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["data"]["succeeded"] == [
|
||||||
|
{"filename": "demo_skill.zip", "name": "demo_skill"}
|
||||||
|
]
|
||||||
|
assert data["data"]["failed"] == []
|
||||||
|
assert (skills_dir / "demo_skill" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_upload_skills_partial_success(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
async def _fake_sync_skills_to_active_sandboxes():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _fake_install_skill_from_zip(
|
||||||
|
self,
|
||||||
|
zip_path: str,
|
||||||
|
*,
|
||||||
|
overwrite: bool = True,
|
||||||
|
):
|
||||||
|
_ = self, overwrite
|
||||||
|
if "ok_skill" in zip_path:
|
||||||
|
return "ok_skill"
|
||||||
|
raise RuntimeError("install failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
|
||||||
|
_fake_sync_skills_to_active_sandboxes,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
|
||||||
|
_fake_install_skill_from_zip,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
boundary = "----AstrBotBatchBoundary"
|
||||||
|
body = (
|
||||||
|
(
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
'Content-Disposition: form-data; name="files"; filename="ok_skill.zip"\r\n'
|
||||||
|
"Content-Type: application/zip\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
+ b"fake-zip-1\r\n"
|
||||||
|
+ (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
'Content-Disposition: form-data; name="files"; filename="bad_skill.zip"\r\n'
|
||||||
|
"Content-Type: application/zip\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
+ b"fake-zip-2\r\n"
|
||||||
|
+ f"--{boundary}--\r\n".encode()
|
||||||
|
)
|
||||||
|
headers = dict(authenticated_header)
|
||||||
|
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
||||||
|
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/skills/batch-upload",
|
||||||
|
headers=headers,
|
||||||
|
data=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["message"] == "Partial success: 1/2 skill(s) uploaded."
|
||||||
|
assert data["data"]["total"] == 2
|
||||||
|
assert data["data"]["succeeded"] == [
|
||||||
|
{"filename": "ok_skill.zip", "name": "ok_skill"}
|
||||||
|
]
|
||||||
|
assert data["data"]["failed"] == [
|
||||||
|
{"filename": "bad_skill.zip", "error": "install failed"}
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
!data
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"type": "card",
|
||||||
|
"theme": "info",
|
||||||
|
"size": "lg",
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"text": {
|
||||||
|
"content": "test1",
|
||||||
|
"type": "plain-text",
|
||||||
|
"emoji": true
|
||||||
|
},
|
||||||
|
"type": "header"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": {
|
||||||
|
"content": "test2",
|
||||||
|
"type": "kmarkdown"
|
||||||
|
},
|
||||||
|
"type": "section",
|
||||||
|
"mode": "left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "divider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"content": "test3",
|
||||||
|
"type": "kmarkdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "**test4**",
|
||||||
|
"type": "kmarkdown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph",
|
||||||
|
"cols": 2
|
||||||
|
},
|
||||||
|
"type": "section",
|
||||||
|
"mode": "left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||||
|
"type": "image",
|
||||||
|
"alt": "",
|
||||||
|
"size": "lg",
|
||||||
|
"circle": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "image-group"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||||
|
"title": "test5",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"endTime": 1772343427360,
|
||||||
|
"type": "countdown",
|
||||||
|
"startTime": 1772343378259,
|
||||||
|
"mode": "second"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"text": "点我测试回调",
|
||||||
|
"type": "button",
|
||||||
|
"theme": "primary",
|
||||||
|
"value": "btn_clicked",
|
||||||
|
"click": "return-val"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "访问官网",
|
||||||
|
"type": "button",
|
||||||
|
"theme": "danger",
|
||||||
|
"value": "https://www.kookapp.cn",
|
||||||
|
"click": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "action-group"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"content": "test6",
|
||||||
|
"type": "plain-text",
|
||||||
|
"emoji": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "context"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "test7",
|
||||||
|
"type": "invite"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TEST_DATA_DIR = Path(__file__).parent / "data"
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata, Unknown
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
from astrbot.core.message.components import (
|
||||||
|
File,
|
||||||
|
Image,
|
||||||
|
Plain,
|
||||||
|
Video,
|
||||||
|
At,
|
||||||
|
AtAll,
|
||||||
|
BaseMessageComponent,
|
||||||
|
Json,
|
||||||
|
Record,
|
||||||
|
Reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from astrbot.core.platform.sources.kook.kook_event import KookEvent
|
||||||
|
from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage
|
||||||
|
|
||||||
|
|
||||||
|
async def mock_kook_client(upload_asset_return: str, send_text_return: str):
|
||||||
|
# 1. Mock 掉整个 KookClient 类
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
client.upload_asset = AsyncMock(return_value=upload_asset_return)
|
||||||
|
client.send_text = AsyncMock(return_value=send_text_return)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def mock_file_message(input: str):
|
||||||
|
message = MagicMock(spec=File)
|
||||||
|
message.get_file = AsyncMock(return_value=input)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def mock_record_message(input: str):
|
||||||
|
message = MagicMock(spec=Record)
|
||||||
|
message.text = input
|
||||||
|
message.convert_to_file_path = AsyncMock(return_value=input)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def mock_astrbot_message():
|
||||||
|
message = AstrBotMessage()
|
||||||
|
message.type = MessageType.OTHER_MESSAGE
|
||||||
|
message.group_id = "test"
|
||||||
|
message.session_id = "test"
|
||||||
|
message.message_id = "test"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_message,upload_asset_return, expected_output, expected_error",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
Image("test image"),
|
||||||
|
"test image",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="test image",
|
||||||
|
type=KookMessageType.IMAGE,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Video("test video"),
|
||||||
|
"test video",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="test video",
|
||||||
|
type=KookMessageType.VIDEO,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
mock_file_message("test file"),
|
||||||
|
"test file",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="test file",
|
||||||
|
type=KookMessageType.FILE,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
mock_record_message("./tests/file.wav"),
|
||||||
|
"./tests/file.wav",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
|
||||||
|
type=KookMessageType.CARD,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Plain("test plain"),
|
||||||
|
"test plain",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="test plain",
|
||||||
|
type=KookMessageType.KMARKDOWN,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
At(qq="test at"),
|
||||||
|
"test at",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="(met)test at(met)",
|
||||||
|
type=KookMessageType.KMARKDOWN,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AtAll(qq="all"),
|
||||||
|
"test atAll",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="(met)all(met)",
|
||||||
|
type=KookMessageType.KMARKDOWN,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Reply(id="test reply"),
|
||||||
|
"test reply",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text="",
|
||||||
|
type=KookMessageType.KMARKDOWN,
|
||||||
|
reply_id="test reply",
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Json(data={"test": "json"}),
|
||||||
|
"test json",
|
||||||
|
OrderMessage(
|
||||||
|
1,
|
||||||
|
text='[{"test": "json"}]',
|
||||||
|
type=KookMessageType.CARD,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Unknown(text="test unknown"),
|
||||||
|
"test unknown",
|
||||||
|
None,
|
||||||
|
NotImplementedError,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_kook_event_warp_message(
|
||||||
|
input_message: BaseMessageComponent,
|
||||||
|
upload_asset_return: str,
|
||||||
|
expected_output: OrderMessage,
|
||||||
|
expected_error: type[Exception] | None,
|
||||||
|
):
|
||||||
|
client = await mock_kook_client(
|
||||||
|
upload_asset_return,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
event = KookEvent(
|
||||||
|
"",
|
||||||
|
mock_astrbot_message(),
|
||||||
|
PlatformMetadata(
|
||||||
|
name="test",
|
||||||
|
id="test",
|
||||||
|
description="test",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
client,
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_error:
|
||||||
|
with pytest.raises(expected_error):
|
||||||
|
await event._wrap_message(1, input_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await event._wrap_message(1, input_message)
|
||||||
|
assert result == expected_output
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.mark.asyncio
|
||||||
|
# @pytest.mark.parametrize(
|
||||||
|
# "message_chain,send_text_expected_output,expected_error",
|
||||||
|
# [
|
||||||
|
# (
|
||||||
|
# MessageChain(
|
||||||
|
# chain=[
|
||||||
|
# Image(file="test image"),
|
||||||
|
# Plain(text="test plain"),
|
||||||
|
# ],
|
||||||
|
# ),
|
||||||
|
# ""
|
||||||
|
# ),
|
||||||
|
# ],
|
||||||
|
# )
|
||||||
|
# async def test_kook_event_send():
|
||||||
|
# client = await mock_kook_client(
|
||||||
|
# "",
|
||||||
|
# "",
|
||||||
|
# )
|
||||||
|
|
||||||
|
# event = KookEvent(
|
||||||
|
# "",
|
||||||
|
# mock_astrbot_message(),
|
||||||
|
# PlatformMetadata(
|
||||||
|
# name="test",
|
||||||
|
# id="test",
|
||||||
|
# description="test",
|
||||||
|
# ),
|
||||||
|
# "",
|
||||||
|
# client,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# await event.send(message=mock_astrbot_message())
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from astrbot.core.platform.sources.kook.kook_types import (
|
||||||
|
ActionGroupModule,
|
||||||
|
ButtonElement,
|
||||||
|
ContextModule,
|
||||||
|
CountdownModule,
|
||||||
|
DividerModule,
|
||||||
|
FileModule,
|
||||||
|
HeaderModule,
|
||||||
|
ImageElement,
|
||||||
|
ImageGroupModule,
|
||||||
|
InviteModule,
|
||||||
|
KmarkdownElement,
|
||||||
|
KookCardMessage,
|
||||||
|
ParagraphStructure,
|
||||||
|
PlainTextElement,
|
||||||
|
SectionModule,
|
||||||
|
KookCardMessageContainer,
|
||||||
|
)
|
||||||
|
from tests.test_kook.shared import TEST_DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_kook_card_message_container_append():
|
||||||
|
container = KookCardMessageContainer()
|
||||||
|
container.append(KookCardMessage())
|
||||||
|
assert len(container) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input, expect_container_length",
|
||||||
|
[
|
||||||
|
([KookCardMessage()], 1),
|
||||||
|
([KookCardMessage()] * 2, 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_kook_card_message_container_to_json(
|
||||||
|
input: list[KookCardMessage], expect_container_length: int
|
||||||
|
):
|
||||||
|
container = KookCardMessageContainer(input)
|
||||||
|
json_output = container.to_json()
|
||||||
|
output = json.loads(json_output)
|
||||||
|
assert isinstance(output, list)
|
||||||
|
assert len(output) == expect_container_length
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_kook_card_type():
|
||||||
|
expect_json_data = Path(TEST_DATA_DIR / "kook_card_data.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
json_output = KookCardMessage(
|
||||||
|
theme="info",
|
||||||
|
size="lg",
|
||||||
|
modules=[
|
||||||
|
HeaderModule(text=PlainTextElement(content="test1")),
|
||||||
|
SectionModule(text=KmarkdownElement(content="test2")),
|
||||||
|
DividerModule(),
|
||||||
|
SectionModule(
|
||||||
|
text=ParagraphStructure(
|
||||||
|
cols=2,
|
||||||
|
fields=[
|
||||||
|
KmarkdownElement(content="test3"),
|
||||||
|
KmarkdownElement(content="**test4**"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ImageGroupModule(
|
||||||
|
elements=[
|
||||||
|
ImageElement(
|
||||||
|
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
FileModule(
|
||||||
|
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||||
|
title="test5",
|
||||||
|
type="file",
|
||||||
|
),
|
||||||
|
CountdownModule(
|
||||||
|
endTime=1772343427360,
|
||||||
|
startTime=1772343378259,
|
||||||
|
mode="second",
|
||||||
|
),
|
||||||
|
ActionGroupModule(
|
||||||
|
elements=[
|
||||||
|
ButtonElement(
|
||||||
|
value="btn_clicked",
|
||||||
|
text="点我测试回调",
|
||||||
|
click="return-val",
|
||||||
|
theme="primary",
|
||||||
|
),
|
||||||
|
ButtonElement(
|
||||||
|
value="https://www.kookapp.cn",
|
||||||
|
text="访问官网",
|
||||||
|
click="link",
|
||||||
|
theme="danger",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ContextModule(elements=[PlainTextElement(content="test6")]),
|
||||||
|
InviteModule(code="test7"),
|
||||||
|
],
|
||||||
|
).to_json(indent=4, ensure_ascii=False)
|
||||||
|
assert json_output == expect_json_data
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from astrbot.core.utils.pip_installer import PipInstaller
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_install_targets_site_packages_for_desktop_client(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
|
||||||
|
monkeypatch.delattr("sys.frozen", raising=False)
|
||||||
|
|
||||||
|
site_packages_path = tmp_path / "site-packages"
|
||||||
|
run_pip = AsyncMock(return_value=0)
|
||||||
|
prepend_sys_path_calls = []
|
||||||
|
ensure_preferred_calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(PipInstaller, "_run_pip_in_process", run_pip)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.utils.pip_installer.get_astrbot_site_packages_path",
|
||||||
|
lambda: str(site_packages_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.utils.pip_installer._prepend_sys_path",
|
||||||
|
lambda path: prepend_sys_path_calls.append(path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.utils.pip_installer._ensure_plugin_dependencies_preferred",
|
||||||
|
lambda path, requirements: ensure_preferred_calls.append((path, requirements)),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = PipInstaller("")
|
||||||
|
await installer.install(package_name="demo-package")
|
||||||
|
|
||||||
|
run_pip.assert_awaited_once()
|
||||||
|
recorded_args = run_pip.await_args_list[0].args[0]
|
||||||
|
|
||||||
|
assert "--target" in recorded_args
|
||||||
|
assert str(site_packages_path) in recorded_args
|
||||||
|
assert prepend_sys_path_calls == [str(site_packages_path), str(site_packages_path)]
|
||||||
|
assert ensure_preferred_calls == [(str(site_packages_path), {"demo-package"})]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_root
|
||||||
|
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_client_env_marks_desktop_runtime_without_frozen(monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
|
||||||
|
monkeypatch.delattr("sys.frozen", raising=False)
|
||||||
|
|
||||||
|
assert is_packaged_desktop_runtime() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_client_uses_home_root_without_explicit_astrbot_root(monkeypatch):
|
||||||
|
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
|
||||||
|
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
|
||||||
|
monkeypatch.delattr("sys.frozen", raising=False)
|
||||||
|
|
||||||
|
assert get_astrbot_root().endswith(".astrbot")
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_astrbot_root_overrides_desktop_default(monkeypatch, tmp_path):
|
||||||
|
explicit_root = tmp_path / "astrbot-root"
|
||||||
|
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
|
||||||
|
monkeypatch.setenv("ASTRBOT_ROOT", str(explicit_root))
|
||||||
|
monkeypatch.delattr("sys.frozen", raising=False)
|
||||||
|
|
||||||
|
assert get_astrbot_root() == str(explicit_root.resolve())
|
||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from astrbot.core.skills.skill_manager import SkillManager
|
from astrbot.core.skills.skill_manager import SkillManager
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ def test_list_skills_merges_local_and_sandbox_cache(monkeypatch, tmp_path: Path)
|
|||||||
assert by_name["custom-local"].description == "local description"
|
assert by_name["custom-local"].description == "local description"
|
||||||
assert by_name["custom-local"].path == "skills/custom-local/SKILL.md"
|
assert by_name["custom-local"].path == "skills/custom-local/SKILL.md"
|
||||||
assert by_name["python-sandbox"].description == "ship built-in"
|
assert by_name["python-sandbox"].description == "ship built-in"
|
||||||
assert by_name["python-sandbox"].path == "skills/python-sandbox/SKILL.md"
|
assert by_name["python-sandbox"].path == "/workspace/skills/python-sandbox/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
def test_sandbox_cached_skill_respects_active_and_display_path(
|
def test_sandbox_cached_skill_respects_active_and_display_path(
|
||||||
@@ -98,7 +100,58 @@ def test_sandbox_cached_skill_respects_active_and_display_path(
|
|||||||
assert len(all_skills) == 1
|
assert len(all_skills) == 1
|
||||||
assert all_skills[0].path == "/app/skills/browser-automation/SKILL.md"
|
assert all_skills[0].path == "/app/skills/browser-automation/SKILL.md"
|
||||||
|
|
||||||
mgr.set_skill_active("browser-automation", False)
|
with pytest.raises(PermissionError):
|
||||||
active_skills = mgr.list_skills(runtime="sandbox", active_only=True)
|
mgr.set_skill_active("browser-automation", False)
|
||||||
assert active_skills == []
|
|
||||||
|
active_skills = mgr.list_skills(runtime="sandbox", active_only=True)
|
||||||
|
assert len(active_skills) == 1
|
||||||
|
assert active_skills[0].name == "browser-automation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_and_local_path_resolution_with_show_sandbox_path_false(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
temp_dir = tmp_path / "temp"
|
||||||
|
skills_root = tmp_path / "skills"
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
skills_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
|
||||||
|
lambda: str(data_dir),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
|
||||||
|
lambda: str(temp_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr = SkillManager(skills_root=str(skills_root))
|
||||||
|
_write_skill(skills_root, "custom-local", "local description")
|
||||||
|
mgr.set_sandbox_skills_cache(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "custom-local",
|
||||||
|
"description": "cached description should be overridden",
|
||||||
|
"path": "/app/skills/custom-local/SKILL.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "python-sandbox",
|
||||||
|
"description": "ship built-in",
|
||||||
|
"path": "/app/skills/python-sandbox/SKILL.md",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
skills = mgr.list_skills(runtime="sandbox", show_sandbox_path=False)
|
||||||
|
by_name = {item.name: item for item in skills}
|
||||||
|
|
||||||
|
assert sorted(by_name) == ["custom-local", "python-sandbox"]
|
||||||
|
assert by_name["custom-local"].description == "local description"
|
||||||
|
local_skill_path = Path(by_name["custom-local"].path)
|
||||||
|
assert local_skill_path.is_relative_to(skills_root)
|
||||||
|
assert local_skill_path == skills_root / "custom-local" / "SKILL.md"
|
||||||
|
assert by_name["python-sandbox"].path == "/app/skills/python-sandbox/SKILL.md"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import astrbot.core.message.components as Comp
|
||||||
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
from astrbot.core.pipeline.respond.stage import RespondStage
|
||||||
|
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
|
||||||
|
AiocqhttpMessageEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poke_to_dict_matches_onebot_v11_segment_format():
|
||||||
|
poke = Comp.Poke(type="126", id=2003)
|
||||||
|
assert poke.toDict() == {
|
||||||
|
"type": "poke",
|
||||||
|
"data": {"type": "126", "id": "2003"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_poke_to_dict_keeps_legacy_qq_compatible():
|
||||||
|
poke = Comp.Poke(type="poke", qq=2916963017)
|
||||||
|
assert poke.toDict() == {
|
||||||
|
"type": "poke",
|
||||||
|
"data": {"type": "126", "id": "2916963017"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respond_stage_treats_poke_with_target_as_non_empty():
|
||||||
|
stage = RespondStage()
|
||||||
|
chain = [Comp.Poke(type="126", id=2003)]
|
||||||
|
assert await stage._is_empty_message_chain(chain) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aiocqhttp_parse_json_outputs_standard_poke_data():
|
||||||
|
chain = MessageChain([Comp.Poke(type="126", id=2003)])
|
||||||
|
data = await AiocqhttpMessageEvent._parse_onebot_json(chain)
|
||||||
|
assert data == [{"type": "poke", "data": {"type": "126", "id": "2003"}}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aiocqhttp_send_message_dispatches_onebot_v11_poke_payload():
|
||||||
|
bot = AsyncMock()
|
||||||
|
chain = MessageChain([Comp.Poke(type="126", id=2003)])
|
||||||
|
|
||||||
|
await AiocqhttpMessageEvent.send_message(
|
||||||
|
bot=bot,
|
||||||
|
message_chain=chain,
|
||||||
|
event=None,
|
||||||
|
is_group=True,
|
||||||
|
session_id="123456",
|
||||||
|
)
|
||||||
|
|
||||||
|
bot.send_group_msg.assert_awaited_once_with(
|
||||||
|
group_id=123456,
|
||||||
|
message=[{"type": "poke", "data": {"type": "126", "id": "2003"}}],
|
||||||
|
)
|
||||||
@@ -516,30 +516,6 @@ class TestEnsurePersonaAndSkills:
|
|||||||
|
|
||||||
assert "Persona Instructions" not in req.system_prompt
|
assert "Persona Instructions" not in req.system_prompt
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ensure_skills(self, mock_event, mock_context):
|
|
||||||
"""Test applying skills to request."""
|
|
||||||
module = ama
|
|
||||||
mock_skill = MagicMock()
|
|
||||||
mock_skill.name = "test_skill"
|
|
||||||
mock_skill.to_prompt.return_value = "Skill description"
|
|
||||||
mock_context.persona_manager.personas_v3 = []
|
|
||||||
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
|
|
||||||
return_value=(None, None, None, False)
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("astrbot.core.astr_main_agent.SkillManager") as mock_skill_mgr_cls:
|
|
||||||
mock_skill_mgr = MagicMock()
|
|
||||||
mock_skill_mgr.list_skills.return_value = [mock_skill]
|
|
||||||
mock_skill_mgr_cls.return_value = mock_skill_mgr
|
|
||||||
|
|
||||||
req = ProviderRequest()
|
|
||||||
req.conversation = MagicMock(persona_id=None)
|
|
||||||
|
|
||||||
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
|
|
||||||
|
|
||||||
assert "test_skill" in req.system_prompt
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ensure_tools_from_persona(self, mock_event, mock_context):
|
async def test_ensure_tools_from_persona(self, mock_event, mock_context):
|
||||||
"""Test applying tools from persona."""
|
"""Test applying tools from persona."""
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import platform
|
||||||
|
from astrbot.core.computer.tools.python import PythonTool, LocalPythonTool
|
||||||
|
|
||||||
|
def test_python_tool_description_contains_os():
|
||||||
|
"""测试 PythonTool 的描述中是否包含当前操作系统信息"""
|
||||||
|
tool = PythonTool()
|
||||||
|
current_os = platform.system()
|
||||||
|
assert current_os in tool.description
|
||||||
|
assert "IPython" in tool.description
|
||||||
|
|
||||||
|
def test_local_python_tool_description_contains_os():
|
||||||
|
"""测试 LocalPythonTool 的描述中是否包含当前操作系统信息和兼容性提示"""
|
||||||
|
tool = LocalPythonTool()
|
||||||
|
current_os = platform.system()
|
||||||
|
assert current_os in tool.description
|
||||||
|
assert "Python environment" in tool.description
|
||||||
|
assert "system-compatible" in tool.description
|
||||||
Reference in New Issue
Block a user