mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-13 16:30:15 +08:00
Compare commits
35 Commits
feat/runti
...
feat/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ada40314 | ||
|
|
fb43c06123 | ||
|
|
00689604b4 | ||
|
|
960bc21c53 | ||
|
|
1199b704a8 | ||
|
|
b40bcbbd86 | ||
|
|
fd2ca702d7 | ||
|
|
b2a95713f8 | ||
|
|
fbe9a38c42 | ||
|
|
29a449f90d | ||
|
|
e98eb92b5f | ||
|
|
352455197d | ||
|
|
47f78be378 | ||
|
|
a1a7de1c57 | ||
|
|
0ca6ba91b1 | ||
|
|
5be6536f0e | ||
|
|
087c793615 | ||
|
|
89096411d2 | ||
|
|
22e8cbd10d | ||
|
|
ee85a4e50f | ||
|
|
a8660ff21e | ||
|
|
469f498428 | ||
|
|
34cf4014e6 | ||
|
|
7c39abc6b5 | ||
|
|
cb91dfb6f7 | ||
|
|
49531da91d | ||
|
|
625eab223f | ||
|
|
207eb34ba2 | ||
|
|
cc72c01c0e | ||
|
|
11dedf3802 | ||
|
|
631e5fe152 | ||
|
|
b342cf9997 | ||
|
|
1292faa446 | ||
|
|
abd11d5579 | ||
|
|
afeda9b82a |
20
.github/workflows/build-docs.yml
vendored
20
.github/workflows/build-docs.yml
vendored
@@ -12,15 +12,21 @@ jobs:
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5.0.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
node-version: "24.13.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: docs/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: './docs'
|
||||
- name: Build docs
|
||||
run: pnpm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
|
||||
15
.github/workflows/dashboard_ci.yml
vendored
15
.github/workflows/dashboard_ci.yml
vendored
@@ -14,17 +14,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5.0.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: dashboard/pnpm-lock.yaml
|
||||
|
||||
- name: npm install, build
|
||||
- name: Install and Build
|
||||
working-directory: dashboard
|
||||
run: |
|
||||
cd dashboard
|
||||
npm install pnpm -g
|
||||
pnpm install
|
||||
pnpm i --save-dev @types/markdown-it
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
|
||||
- name: Inject Commit SHA
|
||||
|
||||
4
.github/workflows/docker-image.yml
vendored
4
.github/workflows/docker-image.yml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const title = (context.payload.pull_request.title || "").trim();
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -64,11 +64,11 @@ jobs:
|
||||
|
||||
- name: Build dashboard dist
|
||||
shell: bash
|
||||
working-directory: dashboard
|
||||
run: |
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir dashboard run build
|
||||
echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version
|
||||
cd dashboard
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
|
||||
49
.github/workflows/smoke_test.yml
vendored
49
.github/workflows/smoke_test.yml
vendored
@@ -13,10 +13,23 @@ on:
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
name: Run smoke tests
|
||||
runs-on: ubuntu-latest
|
||||
name: Smoke test (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
python-version:
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
- '3.14'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -26,33 +39,21 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install UV package manager
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install uv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync
|
||||
uv pip install --system -r requirements.txt
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
uv run main.py &
|
||||
APP_PID=$!
|
||||
|
||||
echo "Waiting for application to start..."
|
||||
for i in {1..60}; do
|
||||
if curl -f http://localhost:6185 > /dev/null 2>&1; then
|
||||
echo "Application started successfully!"
|
||||
kill $APP_PID
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Application failed to start within 30 seconds"
|
||||
kill $APP_PID 2>/dev/null || true
|
||||
exit 1
|
||||
python scripts/smoke_startup_check.py
|
||||
timeout-minutes: 2
|
||||
|
||||
14
README.md
14
README.md
@@ -77,20 +77,21 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12.
|
||||
|
||||
> [!NOTE]
|
||||
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
> For macOS users: 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
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -100,7 +101,7 @@ uv tool upgrade 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Deploy on RainYun
|
||||
|
||||
@@ -138,7 +139,7 @@ yay -S astrbot-git
|
||||
|
||||
**More deployment methods**
|
||||
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
@@ -157,11 +158,12 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Discord | Official |
|
||||
| LINE | Official |
|
||||
| Satori | Official |
|
||||
| KOOK | Official |
|
||||
| Misskey | Official |
|
||||
| Mattermost | Official |
|
||||
| WhatsApp (Coming Soon) | Official |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Community |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||
|
||||
## Supported Model Services
|
||||
|
||||
13
README_fr.md
13
README_fr.md
@@ -76,12 +76,13 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
> AstrBot nécessite Python 3.12 ou une version plus récente. L'option `--python 3.12` garantit que `uv` crée l'environnement tool avec Python 3.12.
|
||||
|
||||
> [!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).
|
||||
@@ -89,7 +90,7 @@ astrbot run
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -99,7 +100,7 @@ uv tool upgrade 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Déployer sur RainYun
|
||||
|
||||
@@ -137,7 +138,7 @@ yay -S astrbot-git
|
||||
|
||||
**Autres méthodes de déploiement**
|
||||
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://docs.astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
@@ -156,10 +157,12 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| Discord | Officielle |
|
||||
| LINE | Officielle |
|
||||
| Satori | Officielle |
|
||||
| KOOK | Officielle |
|
||||
| Misskey | Officielle |
|
||||
| Mattermost | Officielle |
|
||||
| WhatsApp (Bientôt disponible) | Officielle |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Communauté |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||
|
||||
## Services de modèles pris en charge
|
||||
|
||||
13
README_ja.md
13
README_ja.md
@@ -76,12 +76,13 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
> AstrBot には Python 3.12 以降が必要です。`--python 3.12` を指定すると、`uv` は Python 3.12 で tool 環境を作成します。
|
||||
|
||||
> [!NOTE]
|
||||
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
|
||||
@@ -89,7 +90,7 @@ astrbot run
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -99,7 +100,7 @@ uv tool upgrade 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
### 雨云でのデプロイ
|
||||
|
||||
@@ -137,7 +138,7 @@ yay -S astrbot-git
|
||||
|
||||
**その他のデプロイ方法**
|
||||
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://docs.astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
@@ -156,10 +157,12 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Discord | 公式 |
|
||||
| LINE | 公式 |
|
||||
| Satori | 公式 |
|
||||
| KOOK | 公式 |
|
||||
| Misskey | 公式 |
|
||||
| Mattermost | 公式 |
|
||||
| WhatsApp (近日対応予定) | 公式 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | コミュニティ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
|
||||
|
||||
|
||||
|
||||
13
README_ru.md
13
README_ru.md
@@ -76,12 +76,13 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
> Для AstrBot требуется Python 3.12 или новее. Параметр `--python 3.12` гарантирует, что `uv` создаст tool-окружение с Python 3.12.
|
||||
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||
@@ -89,7 +90,7 @@ astrbot run
|
||||
Обновить `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -99,7 +100,7 @@ uv tool upgrade astrbot
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Развёртывание на RainYun
|
||||
|
||||
@@ -137,7 +138,7 @@ yay -S astrbot-git
|
||||
|
||||
**Другие способы развёртывания**
|
||||
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://docs.astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
@@ -156,10 +157,12 @@ yay -S astrbot-git
|
||||
| Discord | Официальная |
|
||||
| LINE | Официальная |
|
||||
| Satori | Официальная |
|
||||
| KOOK | Официальная |
|
||||
| Misskey | Официальная |
|
||||
| Mattermost | Официальная |
|
||||
| WhatsApp (Скоро) | Официальная |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Сообщество |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||
|
||||
## Поддерживаемые сервисы моделей
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<a href="https://astrbot.app/">文件</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a> |
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
</div>
|
||||
|
||||
@@ -76,12 +76,13 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 會確保 `uv` 使用 Python 3.12 建立 tool 環境。
|
||||
|
||||
> [!NOTE]
|
||||
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
|
||||
@@ -89,7 +90,7 @@ astrbot run
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -99,7 +100,7 @@ uv tool upgrade 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在雨雲上部署
|
||||
|
||||
@@ -137,7 +138,7 @@ yay -S astrbot-git
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
@@ -156,10 +157,12 @@ yay -S astrbot-git
|
||||
| Discord | 官方維護 |
|
||||
| LINE | 官方維護 |
|
||||
| Satori | 官方維護 |
|
||||
| KOOK | 官方維護 |
|
||||
| Misskey | 官方維護 |
|
||||
| Whatsapp(即將支援) | 官方維護 |
|
||||
| Mattermost | 官方維護 |
|
||||
| WhatsApp(即將支援) | 官方維護 |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
|
||||
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
|
||||
|
||||
## 支援的模型服務
|
||||
|
||||
19
README_zh.md
19
README_zh.md
@@ -31,12 +31,12 @@
|
||||
<a href="https://astrbot.app/">文档</a> |
|
||||
<a href="https://blog.astrbot.app/">博客</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a> |
|
||||
<a href="mailto:community@astrbot.app">Email</a>
|
||||
|
||||
</div>
|
||||
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||

|
||||
|
||||
@@ -76,12 +76,13 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 会确保 `uv` 使用 Python 3.12 创建 tool 环境。
|
||||
|
||||
> [!NOTE]
|
||||
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||
@@ -89,7 +90,7 @@ astrbot run
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
uv tool upgrade astrbot --python 3.12
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -99,7 +100,7 @@ uv tool upgrade 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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
@@ -137,7 +138,7 @@ yay -S astrbot-git
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -156,10 +157,12 @@ yay -S astrbot-git
|
||||
| **Discord** | 官方维护 |
|
||||
| **LINE** | 官方维护 |
|
||||
| **Satori** | 官方维护 |
|
||||
| **KOOK** | 官方维护 |
|
||||
| **Misskey** | 官方维护 |
|
||||
| **Whatsapp (将支持)** | 官方维护 |
|
||||
| **Mattermost** | 官方维护 |
|
||||
| **WhatsApp(将支持)** | 官方维护 |
|
||||
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
|
||||
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
|
||||
| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 |
|
||||
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
|
||||
|
||||
## 支持的模型提供商
|
||||
|
||||
@@ -14,6 +14,8 @@ from astrbot.core.star.register import register_command_group as command_group
|
||||
from astrbot.core.star.register import register_custom_filter as custom_filter
|
||||
from astrbot.core.star.register import register_event_message_type as event_message_type
|
||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||
from astrbot.core.star.register import register_on_agent_begin as on_agent_begin
|
||||
from astrbot.core.star.register import register_on_agent_done as on_agent_done
|
||||
from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded
|
||||
from astrbot.core.star.register import (
|
||||
register_on_decorating_result as on_decorating_result,
|
||||
@@ -51,6 +53,8 @@ __all__ = [
|
||||
"custom_filter",
|
||||
"event_message_type",
|
||||
"llm_tool",
|
||||
"on_agent_begin",
|
||||
"on_agent_done",
|
||||
"on_astrbot_loaded",
|
||||
"on_decorating_result",
|
||||
"on_llm_request",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .admin import AdminCommands
|
||||
from .conversation import ConversationCommands
|
||||
from .help import HelpCommand
|
||||
from .provider import ProviderCommands
|
||||
from .setunset import SetUnsetCommands
|
||||
from .sid import SIDCommand
|
||||
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"AdminCommands",
|
||||
"ConversationCommands",
|
||||
"HelpCommand",
|
||||
"ProviderCommands",
|
||||
"SetUnsetCommands",
|
||||
"SIDCommand",
|
||||
]
|
||||
|
||||
248
astrbot/builtin_stars/builtin_commands/commands/provider.py
Normal file
248
astrbot/builtin_stars/builtin_commands/commands/provider.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.utils.error_redaction import safe_error
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
provider,
|
||||
provider_capability_type: ProviderType | None,
|
||||
err_code: str,
|
||||
err_reason: str,
|
||||
) -> None:
|
||||
meta = provider.meta()
|
||||
logger.warning(
|
||||
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
|
||||
meta.id,
|
||||
provider_capability_type.name if provider_capability_type else "unknown",
|
||||
err_code,
|
||||
err_reason,
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
try:
|
||||
await provider.test()
|
||||
return True, None, None
|
||||
except Exception as e:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = safe_error("", e)
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def _build_provider_display_data(
|
||||
self,
|
||||
providers,
|
||||
provider_type: str,
|
||||
reachability_check_enabled: bool,
|
||||
) -> list[dict]:
|
||||
if not providers:
|
||||
return []
|
||||
|
||||
if reachability_check_enabled:
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(provider) for provider in providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
check_results = [None for _ in providers]
|
||||
|
||||
display_data = []
|
||||
for provider, reachable in zip(providers, check_results):
|
||||
meta = provider.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, asyncio.CancelledError):
|
||||
raise reachable
|
||||
if isinstance(reachable, Exception):
|
||||
self._log_reachability_failure(
|
||||
provider,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
safe_error("", reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
if provider_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(errcode: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = ""
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": provider,
|
||||
}
|
||||
)
|
||||
|
||||
return display_data
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
idx: str | int | None = None,
|
||||
idx2: int | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换 LLM Provider"""
|
||||
umo = event.unified_msg_origin
|
||||
cfg = self.context.get_config(umo).get("provider_settings", {})
|
||||
reachability_check_enabled = cfg.get("reachability_check", True)
|
||||
|
||||
if idx is None:
|
||||
parts = ["## LLM Providers\n"]
|
||||
|
||||
llms = list(self.context.get_all_providers())
|
||||
ttss = self.context.get_all_tts_providers()
|
||||
stts = self.context.get_all_stt_providers()
|
||||
|
||||
if reachability_check_enabled and (llms or ttss or stts):
|
||||
await event.send(
|
||||
MessageEventResult().message("👀 Testing provider reachability...")
|
||||
)
|
||||
|
||||
llm_data, tts_data, stt_data = await asyncio.gather(
|
||||
self._build_provider_display_data(
|
||||
llms,
|
||||
"llm",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
self._build_provider_display_data(
|
||||
ttss,
|
||||
"tts",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
self._build_provider_display_data(
|
||||
stts,
|
||||
"stt",
|
||||
reachability_check_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
for i, d in enumerate(llm_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
if (
|
||||
provider_using
|
||||
and provider_using.meta().id == d["provider"].meta().id
|
||||
):
|
||||
line += " 👈"
|
||||
parts.append(line + "\n")
|
||||
|
||||
if tts_data:
|
||||
parts.append("\n## TTS Providers\n")
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
for i, d in enumerate(tts_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
if tts_using and tts_using.meta().id == d["provider"].meta().id:
|
||||
line += " 👈"
|
||||
parts.append(line + "\n")
|
||||
|
||||
if stt_data:
|
||||
parts.append("\n## STT Providers\n")
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
for i, d in enumerate(stt_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
if stt_using and stt_using.meta().id == d["provider"].meta().id:
|
||||
line += " 👈"
|
||||
parts.append(line + "\n")
|
||||
|
||||
parts.append("\nUse /provider <idx> to switch LLM providers.")
|
||||
ret = "".join(parts)
|
||||
|
||||
if ttss:
|
||||
ret += "\nUse /provider tts <idx> to switch TTS providers."
|
||||
if stts:
|
||||
ret += "\nUse /provider stt <idx> to switch STT providers."
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
elif idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(
|
||||
MessageEventResult().message("Please enter the index.")
|
||||
)
|
||||
return
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index.")
|
||||
)
|
||||
return
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
|
||||
)
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(
|
||||
MessageEventResult().message("Please enter the index.")
|
||||
)
|
||||
return
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index.")
|
||||
)
|
||||
return
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
|
||||
)
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(
|
||||
MessageEventResult().message("❌ Invalid provider index.")
|
||||
)
|
||||
return
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
|
||||
)
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("❌ Invalid parameter."))
|
||||
@@ -5,6 +5,7 @@ from .commands import (
|
||||
AdminCommands,
|
||||
ConversationCommands,
|
||||
HelpCommand,
|
||||
ProviderCommands,
|
||||
SetUnsetCommands,
|
||||
SIDCommand,
|
||||
)
|
||||
@@ -17,6 +18,7 @@ class Main(star.Star):
|
||||
self.admin_c = AdminCommands(self.context)
|
||||
self.conversation_c = ConversationCommands(self.context)
|
||||
self.help_c = HelpCommand(self.context)
|
||||
self.provider_c = ProviderCommands(self.context)
|
||||
self.setunset_c = SetUnsetCommands(self.context)
|
||||
self.sid_c = SIDCommand(self.context)
|
||||
|
||||
@@ -45,6 +47,17 @@ class Main(star.Star):
|
||||
"""Create new conversation"""
|
||||
await self.conversation_c.new_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("provider")
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
idx: str | int | None = None,
|
||||
idx2: int | None = None,
|
||||
) -> None:
|
||||
"""View or switch LLM Provider"""
|
||||
await self.provider_c.provider(event, idx, idx2)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dashboard_update")
|
||||
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.23.0"
|
||||
__version__ = "4.23.2"
|
||||
|
||||
@@ -924,8 +924,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# in 'skills_like' mode, raw.func_tool is light schema, does not have handler
|
||||
# so we need to get the tool from the raw tool set
|
||||
func_tool = self._skill_like_raw_tool_set.get_tool(func_tool_name)
|
||||
available_tools = self._skill_like_raw_tool_set.names()
|
||||
else:
|
||||
func_tool = req.func_tool.get_tool(func_tool_name)
|
||||
available_tools = req.func_tool.names()
|
||||
|
||||
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
|
||||
|
||||
@@ -933,7 +935,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
f"error: Tool {func_tool_name} not found.",
|
||||
f"error: Tool {func_tool_name} not found. Available tools are: {', '.join(available_tools)}",
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ from astrbot.core.star.star_handler import EventType
|
||||
|
||||
|
||||
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||
async def on_agent_begin(
|
||||
self, run_context: ContextWrapper[AstrAgentContext]
|
||||
) -> None:
|
||||
await call_event_hook(
|
||||
run_context.context.event,
|
||||
EventType.OnAgentBeginEvent,
|
||||
run_context,
|
||||
)
|
||||
|
||||
async def on_agent_done(self, run_context, llm_response) -> None:
|
||||
# 执行事件钩子
|
||||
if llm_response and llm_response.reasoning_content:
|
||||
@@ -25,6 +34,12 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||
EventType.OnLLMResponseEvent,
|
||||
llm_response,
|
||||
)
|
||||
await call_event_hook(
|
||||
run_context.context.event,
|
||||
EventType.OnAgentDoneEvent,
|
||||
run_context,
|
||||
llm_response,
|
||||
)
|
||||
|
||||
async def on_tool_start(
|
||||
self,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.23.0"
|
||||
VERSION = "4.23.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -1206,7 +1206,7 @@ CONFIG_METADATA_2 = {
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.kimi.com/coding/",
|
||||
"api_base": "https://api.kimi.com/coding",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
@@ -1236,6 +1236,19 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"MiniMax Token Plan": {
|
||||
"id": "minimax-token-plan",
|
||||
"provider": "minimax-token-plan",
|
||||
"type": "minimax_token_plan",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.minimaxi.com/anthropic",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
|
||||
@@ -22,6 +22,12 @@ if TYPE_CHECKING:
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
|
||||
class CronJobSchedulingError(Exception):
|
||||
"""Raised when a cron job fails to be scheduled."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CronJobManager:
|
||||
"""Central scheduler for BasicCronJob and ActiveAgentCronJob."""
|
||||
|
||||
@@ -59,7 +65,10 @@ class CronJobManager:
|
||||
job.job_id,
|
||||
)
|
||||
continue
|
||||
self._schedule_job(job)
|
||||
try:
|
||||
self._schedule_job(job)
|
||||
except CronJobSchedulingError:
|
||||
continue # Error already logged in _schedule_job
|
||||
|
||||
async def add_basic_job(
|
||||
self,
|
||||
@@ -181,12 +190,15 @@ class CronJobManager:
|
||||
job.job_id, next_run_time=self._get_next_run_time(job.job_id)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule cron job {job.job_id}: {e!s}")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.exception("Failed to schedule cron job %s", job.job_id)
|
||||
raise CronJobSchedulingError(str(e)) from e
|
||||
|
||||
def _get_next_run_time(self, job_id: str):
|
||||
aps_job = self.scheduler.get_job(job_id)
|
||||
return aps_job.next_run_time if aps_job else None
|
||||
if not aps_job or aps_job.next_run_time is None:
|
||||
return None
|
||||
return aps_job.next_run_time.astimezone(timezone.utc)
|
||||
|
||||
async def _run_job(self, job_id: str) -> None:
|
||||
job = await self.db.get_cron_job(job_id)
|
||||
|
||||
@@ -2,13 +2,22 @@ import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import Column, Text
|
||||
from sqlalchemy import Column, Text, bindparam
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import Field, MetaData, SQLModel, col, func, select, text
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.knowledge_base.retrieval.tokenizer import (
|
||||
build_fts5_or_query,
|
||||
load_stopwords,
|
||||
to_fts5_search_text,
|
||||
)
|
||||
|
||||
FTS_TABLE_NAME = "documents_fts"
|
||||
FTS_REBUILD_BATCH_SIZE = 1000
|
||||
|
||||
|
||||
class BaseDocModel(SQLModel, table=False):
|
||||
@@ -42,6 +51,10 @@ class DocumentStorage:
|
||||
os.path.dirname(__file__),
|
||||
"sqlite_init.sql",
|
||||
)
|
||||
self.fts5_available = False
|
||||
self._fts_contentless_delete = False
|
||||
self._fts_index_ready = False
|
||||
self._stopwords: set[str] | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the SQLite database and create the documents table if it doesn't exist."""
|
||||
@@ -78,8 +91,49 @@ class DocumentStorage:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
await self._initialize_fts5(conn)
|
||||
await conn.commit()
|
||||
|
||||
async def _initialize_fts5(self, executor) -> None:
|
||||
try:
|
||||
try:
|
||||
await executor.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME}
|
||||
USING fts5(
|
||||
search_text,
|
||||
content='',
|
||||
contentless_delete=1,
|
||||
tokenize='unicode61'
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
self._fts_contentless_delete = True
|
||||
except Exception:
|
||||
await executor.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME}
|
||||
USING fts5(
|
||||
search_text,
|
||||
content='',
|
||||
tokenize='unicode61'
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
self._fts_contentless_delete = False
|
||||
self.fts5_available = True
|
||||
except Exception as e:
|
||||
self.fts5_available = False
|
||||
self._fts_contentless_delete = False
|
||||
logger.warning(
|
||||
f"SQLite FTS5 is unavailable for document storage {self.db_path}; "
|
||||
f"falling back to in-memory BM25 sparse retrieval: {e}",
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the SQLite database."""
|
||||
if self.engine is None:
|
||||
@@ -100,6 +154,18 @@ class DocumentStorage:
|
||||
async with self.async_session_maker() as session: # type: ignore
|
||||
yield session
|
||||
|
||||
@property
|
||||
def stopwords(self) -> set[str]:
|
||||
if self._stopwords is None:
|
||||
stopwords_path = (
|
||||
Path(__file__).parents[3]
|
||||
/ "knowledge_base"
|
||||
/ "retrieval"
|
||||
/ "hit_stopwords.txt"
|
||||
)
|
||||
self._stopwords = load_stopwords(stopwords_path)
|
||||
return self._stopwords
|
||||
|
||||
async def get_documents(
|
||||
self,
|
||||
metadata_filters: dict,
|
||||
@@ -172,6 +238,8 @@ class DocumentStorage:
|
||||
)
|
||||
session.add(document)
|
||||
await session.flush() # Flush to get the ID
|
||||
if document.id is not None:
|
||||
await self._insert_fts_row(session, int(document.id), text)
|
||||
return document.id # type: ignore
|
||||
|
||||
async def insert_documents_batch(
|
||||
@@ -209,6 +277,7 @@ class DocumentStorage:
|
||||
session.add(document)
|
||||
|
||||
await session.flush() # Flush to get all IDs
|
||||
await self._insert_fts_rows_batch(session, documents, texts)
|
||||
return [doc.id for doc in documents] # type: ignore
|
||||
|
||||
async def delete_document_by_doc_id(self, doc_id: str) -> None:
|
||||
@@ -226,6 +295,8 @@ class DocumentStorage:
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if document:
|
||||
if document.id is not None:
|
||||
await self._delete_fts_row(session, int(document.id), document.text)
|
||||
await session.delete(document)
|
||||
|
||||
async def get_document_by_doc_id(self, doc_id: str):
|
||||
@@ -265,9 +336,13 @@ class DocumentStorage:
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if document:
|
||||
if document.id is not None:
|
||||
await self._delete_fts_row(session, int(document.id), document.text)
|
||||
document.text = new_text
|
||||
document.updated_at = datetime.now()
|
||||
session.add(document)
|
||||
if document.id is not None:
|
||||
await self._insert_fts_row(session, int(document.id), new_text)
|
||||
|
||||
async def delete_documents(self, metadata_filters: dict) -> None:
|
||||
"""Delete documents by their metadata filters.
|
||||
@@ -293,6 +368,7 @@ class DocumentStorage:
|
||||
result = await session.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
await self._delete_fts_rows_batch(session, documents)
|
||||
for doc in documents:
|
||||
await session.delete(doc)
|
||||
|
||||
@@ -323,6 +399,286 @@ class DocumentStorage:
|
||||
count = result.scalar_one_or_none()
|
||||
return count if count is not None else 0
|
||||
|
||||
async def ensure_fts_index(self) -> bool:
|
||||
"""Ensure the FTS5 sparse index exists and matches the documents table."""
|
||||
if not self.fts5_available:
|
||||
return False
|
||||
if self._fts_index_ready:
|
||||
return True
|
||||
|
||||
assert self.engine is not None, "Database connection is not initialized."
|
||||
|
||||
async with self.get_session() as session:
|
||||
doc_count = await self._count_documents_in_session(session)
|
||||
fts_count = await self._count_fts_rows(session)
|
||||
if doc_count == fts_count:
|
||||
self._fts_index_ready = True
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Rebuilding FTS5 sparse index for {self.db_path}: "
|
||||
f"documents={doc_count}, fts_rows={fts_count}",
|
||||
)
|
||||
await self.rebuild_fts_index()
|
||||
return self.fts5_available
|
||||
|
||||
async def rebuild_fts_index(self) -> None:
|
||||
"""Rebuild the contentless FTS5 sparse index from documents."""
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
assert self.engine is not None, "Database connection is not initialized."
|
||||
|
||||
async with self.get_session() as session, session.begin():
|
||||
await session.execute(text(f"DROP TABLE IF EXISTS {FTS_TABLE_NAME}"))
|
||||
await self._initialize_fts5(session)
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
last_id = 0
|
||||
while True:
|
||||
query = (
|
||||
select(Document)
|
||||
.where(col(Document.id) > last_id)
|
||||
.order_by(col(Document.id))
|
||||
.limit(FTS_REBUILD_BATCH_SIZE)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
documents = result.scalars().all()
|
||||
if not documents:
|
||||
break
|
||||
|
||||
await self._insert_fts_rows_batch(
|
||||
session,
|
||||
documents,
|
||||
[doc.text for doc in documents],
|
||||
)
|
||||
last_id = int(documents[-1].id or last_id)
|
||||
|
||||
self._fts_index_ready = True
|
||||
|
||||
async def search_sparse(
|
||||
self,
|
||||
query_tokens: list[str],
|
||||
limit: int,
|
||||
) -> list[dict] | None:
|
||||
"""Search chunks using the FTS5 sparse index.
|
||||
|
||||
Returns None when FTS5 is unavailable so callers can fall back to another
|
||||
sparse retrieval implementation.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
if not await self.ensure_fts_index():
|
||||
return None
|
||||
|
||||
match_query = build_fts5_or_query(query_tokens)
|
||||
if not match_query:
|
||||
return []
|
||||
|
||||
async with self.get_session() as session:
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
d.id AS id,
|
||||
d.doc_id AS doc_id,
|
||||
d.text AS text,
|
||||
d.metadata AS metadata,
|
||||
d.created_at AS created_at,
|
||||
d.updated_at AS updated_at,
|
||||
bm25({FTS_TABLE_NAME}) AS score
|
||||
FROM {FTS_TABLE_NAME}
|
||||
JOIN documents d ON d.id = {FTS_TABLE_NAME}.rowid
|
||||
WHERE {FTS_TABLE_NAME} MATCH :query
|
||||
ORDER BY score ASC, d.id ASC
|
||||
LIMIT :limit
|
||||
""",
|
||||
),
|
||||
{"query": match_query, "limit": int(limit)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"FTS5 sparse search failed for {self.db_path}; "
|
||||
f"falling back to in-memory BM25: {e}",
|
||||
)
|
||||
self.fts5_available = False
|
||||
return None
|
||||
|
||||
rows = result.mappings().all()
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"doc_id": row["doc_id"],
|
||||
"text": row["text"],
|
||||
"metadata": row["metadata"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"score": float(row["score"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def _count_documents_in_session(self, session: AsyncSession) -> int:
|
||||
result = await session.execute(select(func.count(col(Document.id))))
|
||||
count = result.scalar_one_or_none()
|
||||
return int(count or 0)
|
||||
|
||||
async def _count_fts_rows(self, session: AsyncSession) -> int:
|
||||
result = await session.execute(
|
||||
text(f"SELECT count(*) FROM {FTS_TABLE_NAME}"),
|
||||
)
|
||||
count = result.scalar_one_or_none()
|
||||
return int(count or 0)
|
||||
|
||||
async def _insert_fts_row(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
rowid: int,
|
||||
content: str,
|
||||
) -> None:
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
search_text = to_fts5_search_text(content, self.stopwords)
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO {FTS_TABLE_NAME}(rowid, search_text)
|
||||
VALUES (:rowid, :search_text)
|
||||
""",
|
||||
),
|
||||
{"rowid": rowid, "search_text": search_text},
|
||||
)
|
||||
|
||||
async def _insert_fts_rows_batch(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
documents: list[Document],
|
||||
contents: list[str],
|
||||
) -> None:
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
fts_params = [
|
||||
{
|
||||
"rowid": int(doc.id),
|
||||
"search_text": to_fts5_search_text(content, self.stopwords),
|
||||
}
|
||||
for doc, content in zip(documents, contents)
|
||||
if doc.id is not None
|
||||
]
|
||||
if not fts_params:
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO {FTS_TABLE_NAME}(rowid, search_text)
|
||||
VALUES (:rowid, :search_text)
|
||||
""",
|
||||
),
|
||||
fts_params,
|
||||
)
|
||||
|
||||
async def _delete_fts_row(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
rowid: int,
|
||||
content: str,
|
||||
) -> None:
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
if self._fts_contentless_delete:
|
||||
await session.execute(
|
||||
text(f"DELETE FROM {FTS_TABLE_NAME} WHERE rowid = :rowid"),
|
||||
{"rowid": rowid},
|
||||
)
|
||||
return
|
||||
|
||||
if not await self._fts_row_exists(session, rowid):
|
||||
return
|
||||
|
||||
search_text = to_fts5_search_text(content, self.stopwords)
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO {FTS_TABLE_NAME}({FTS_TABLE_NAME}, rowid, search_text)
|
||||
VALUES ('delete', :rowid, :search_text)
|
||||
""",
|
||||
),
|
||||
{"rowid": rowid, "search_text": search_text},
|
||||
)
|
||||
|
||||
async def _delete_fts_rows_batch(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
documents: list[Document],
|
||||
) -> None:
|
||||
if not self.fts5_available:
|
||||
return
|
||||
|
||||
docs_with_ids = [doc for doc in documents if doc.id is not None]
|
||||
if not docs_with_ids:
|
||||
return
|
||||
|
||||
if self._fts_contentless_delete:
|
||||
await session.execute(
|
||||
text(f"DELETE FROM {FTS_TABLE_NAME} WHERE rowid = :rowid"),
|
||||
[{"rowid": int(doc.id)} for doc in docs_with_ids if doc.id is not None],
|
||||
)
|
||||
return
|
||||
|
||||
existing_rowids = await self._existing_fts_rowids(
|
||||
session,
|
||||
[int(doc.id) for doc in docs_with_ids if doc.id is not None],
|
||||
)
|
||||
fts_params = [
|
||||
{
|
||||
"rowid": int(doc.id),
|
||||
"search_text": to_fts5_search_text(doc.text, self.stopwords),
|
||||
}
|
||||
for doc in docs_with_ids
|
||||
if doc.id is not None and int(doc.id) in existing_rowids
|
||||
]
|
||||
if not fts_params:
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO {FTS_TABLE_NAME}({FTS_TABLE_NAME}, rowid, search_text)
|
||||
VALUES ('delete', :rowid, :search_text)
|
||||
""",
|
||||
),
|
||||
fts_params,
|
||||
)
|
||||
|
||||
async def _fts_row_exists(self, session: AsyncSession, rowid: int) -> bool:
|
||||
result = await session.execute(
|
||||
text(f"SELECT 1 FROM {FTS_TABLE_NAME} WHERE rowid = :rowid LIMIT 1"),
|
||||
{"rowid": rowid},
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def _existing_fts_rowids(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
rowids: list[int],
|
||||
) -> set[int]:
|
||||
if not rowids:
|
||||
return set()
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
f"SELECT rowid FROM {FTS_TABLE_NAME} WHERE rowid IN :rowids"
|
||||
).bindparams(bindparam("rowids", expanding=True)),
|
||||
{"rowids": rowids},
|
||||
)
|
||||
return {int(row[0]) for row in result.fetchall()}
|
||||
|
||||
async def get_user_ids(self) -> list[str]:
|
||||
"""Retrieve all user IDs from the documents table.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
import numpy as np
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.exceptions import KnowledgeBaseUploadError
|
||||
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
|
||||
|
||||
from ..base import BaseVecDB, Result
|
||||
@@ -80,6 +81,32 @@ class FaissVecDB(BaseVecDB):
|
||||
)
|
||||
return []
|
||||
|
||||
content_count = len(contents)
|
||||
if len(metadatas) != content_count:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="storage",
|
||||
user_message=(
|
||||
f"存储失败:文本分块数量与元数据数量不一致(期望 {content_count},"
|
||||
f"实际 {len(metadatas)})。"
|
||||
),
|
||||
details={
|
||||
"expected_contents": content_count,
|
||||
"actual_metadatas": len(metadatas),
|
||||
},
|
||||
)
|
||||
if len(ids) != content_count:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="storage",
|
||||
user_message=(
|
||||
f"存储失败:文本分块数量与文档 ID 数量不一致(期望 {content_count},"
|
||||
f"实际 {len(ids)})。"
|
||||
),
|
||||
details={
|
||||
"expected_contents": content_count,
|
||||
"actual_ids": len(ids),
|
||||
},
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
logger.debug(f"Generating embeddings for {len(contents)} contents...")
|
||||
vectors = await self.embedding_provider.get_embeddings_batch(
|
||||
@@ -93,6 +120,20 @@ class FaissVecDB(BaseVecDB):
|
||||
logger.debug(
|
||||
f"Generated embeddings for {len(contents)} contents in {end - start:.2f} seconds.",
|
||||
)
|
||||
if len(vectors) != content_count:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="embedding",
|
||||
user_message=(
|
||||
"向量化失败:嵌入模型返回的向量数量与文本分块数量不一致"
|
||||
f"(期望 {content_count},实际 {len(vectors)})。"
|
||||
"这通常说明当前 Embedding 接口未完整返回批量结果,"
|
||||
"或该服务不兼容当前批量请求格式。"
|
||||
),
|
||||
details={
|
||||
"expected_contents": content_count,
|
||||
"actual_vectors": len(vectors),
|
||||
},
|
||||
)
|
||||
|
||||
# 使用 DocumentStorage 的批量插入方法
|
||||
int_ids = await self.document_storage.insert_documents_batch(
|
||||
@@ -100,9 +141,52 @@ class FaissVecDB(BaseVecDB):
|
||||
contents,
|
||||
metadatas,
|
||||
)
|
||||
if len(int_ids) != content_count:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="storage",
|
||||
user_message=(
|
||||
f"存储失败:写入文档索引后返回的内部 ID 数量与文本分块数量不一致"
|
||||
f"(期望 {content_count},实际 {len(int_ids)})。"
|
||||
),
|
||||
details={
|
||||
"expected_contents": content_count,
|
||||
"actual_int_ids": len(int_ids),
|
||||
},
|
||||
)
|
||||
|
||||
# 批量插入向量到 FAISS
|
||||
vectors_array = np.array(vectors).astype("float32")
|
||||
try:
|
||||
vectors_array = np.asarray(vectors, dtype=np.float32)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="embedding",
|
||||
user_message=(
|
||||
"向量化失败:嵌入模型返回的向量格式不正确,"
|
||||
"无法转换为统一的浮点向量矩阵。"
|
||||
),
|
||||
details={"vector_count": len(vectors)},
|
||||
) from exc
|
||||
if vectors_array.ndim != 2:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="embedding",
|
||||
user_message=(
|
||||
"向量化失败:嵌入模型返回的向量格式不正确,无法构造成二维向量矩阵。"
|
||||
),
|
||||
details={"actual_ndim": int(vectors_array.ndim)},
|
||||
)
|
||||
if vectors_array.shape[1] != self.embedding_storage.dimension:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="embedding",
|
||||
user_message=(
|
||||
"向量化失败:返回向量维度与当前知识库索引维度不一致"
|
||||
f"(期望 {self.embedding_storage.dimension},"
|
||||
f"实际 {vectors_array.shape[1]})。"
|
||||
),
|
||||
details={
|
||||
"expected_dimension": self.embedding_storage.dimension,
|
||||
"actual_dimension": int(vectors_array.shape[1]),
|
||||
},
|
||||
)
|
||||
await self.embedding_storage.insert_batch(vectors_array, int_ids)
|
||||
return int_ids
|
||||
|
||||
|
||||
@@ -11,3 +11,22 @@ class ProviderNotFoundError(AstrBotError):
|
||||
|
||||
class EmptyModelOutputError(AstrBotError):
|
||||
"""Raised when the model response contains no usable assistant output."""
|
||||
|
||||
|
||||
class KnowledgeBaseUploadError(AstrBotError):
|
||||
"""Raised when knowledge base upload fails with a user-facing message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stage: str,
|
||||
user_message: str,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
super().__init__(user_message)
|
||||
self.stage = stage
|
||||
self.user_message = user_message
|
||||
self.details = details or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.user_message
|
||||
|
||||
@@ -10,6 +10,7 @@ import aiofiles
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.db.vec_db.base import BaseVecDB
|
||||
from astrbot.core.exceptions import KnowledgeBaseUploadError
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.provider.provider import (
|
||||
EmbeddingProvider,
|
||||
@@ -264,10 +265,31 @@ class KBHelper:
|
||||
if progress_callback:
|
||||
await progress_callback("parsing", 0, 100)
|
||||
|
||||
parser = await select_parser(f".{file_type}")
|
||||
parse_result = await parser.parse(file_content, file_name)
|
||||
try:
|
||||
parser = await select_parser(f".{file_type}")
|
||||
parse_result = await parser.parse(file_content, file_name)
|
||||
except KnowledgeBaseUploadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="parsing",
|
||||
user_message=(
|
||||
"文档解析失败:无法读取或解析上传文件。"
|
||||
"请确认文件格式受支持且文件内容未损坏。"
|
||||
),
|
||||
details={"file_name": file_name},
|
||||
) from exc
|
||||
text_content = parse_result.text
|
||||
media_items = parse_result.media
|
||||
if not text_content or not text_content.strip():
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="parsing",
|
||||
user_message=(
|
||||
"文档解析失败:未能从文件中提取可索引文本。"
|
||||
"该文件可能是扫描件、纯图片 PDF,或格式暂不受支持。"
|
||||
),
|
||||
details={"file_name": file_name},
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback("parsing", 100, 100)
|
||||
@@ -288,11 +310,40 @@ class KBHelper:
|
||||
if progress_callback:
|
||||
await progress_callback("chunking", 0, 100)
|
||||
|
||||
chunks_text = await self.chunker.chunk(
|
||||
text_content,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
)
|
||||
try:
|
||||
chunks_text = await self.chunker.chunk(
|
||||
text_content,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
)
|
||||
except KnowledgeBaseUploadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="chunking",
|
||||
user_message=(
|
||||
"分块失败:文档内容在切分文本块时发生错误。"
|
||||
"请稍后重试,或调整分块参数后再次上传。"
|
||||
),
|
||||
details={"file_name": file_name},
|
||||
) from exc
|
||||
|
||||
if not chunks_text or not any(chunk.strip() for chunk in chunks_text):
|
||||
if pre_chunked_text is not None:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="validation",
|
||||
user_message=("预分块文本为空,未提供任何可索引文本块。"),
|
||||
details={"file_name": file_name},
|
||||
)
|
||||
else:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="chunking",
|
||||
user_message=(
|
||||
"分块失败:文档内容为空,未生成任何可索引文本块。"
|
||||
),
|
||||
details={"file_name": file_name},
|
||||
)
|
||||
|
||||
contents = []
|
||||
metadatas = []
|
||||
for idx, chunk_text in enumerate(chunks_text):
|
||||
@@ -313,14 +364,23 @@ class KBHelper:
|
||||
if progress_callback:
|
||||
await progress_callback("embedding", current, total)
|
||||
|
||||
await self.vec_db.insert_batch(
|
||||
contents=contents,
|
||||
metadatas=metadatas,
|
||||
batch_size=batch_size,
|
||||
tasks_limit=tasks_limit,
|
||||
max_retries=max_retries,
|
||||
progress_callback=embedding_progress_callback,
|
||||
)
|
||||
try:
|
||||
await self.vec_db.insert_batch(
|
||||
contents=contents,
|
||||
metadatas=metadatas,
|
||||
batch_size=batch_size,
|
||||
tasks_limit=tasks_limit,
|
||||
max_retries=max_retries,
|
||||
progress_callback=embedding_progress_callback,
|
||||
)
|
||||
except KnowledgeBaseUploadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="storage",
|
||||
user_message=("存储失败:文本块已生成,但写入知识库索引时出错。"),
|
||||
details={"file_name": file_name},
|
||||
) from exc
|
||||
|
||||
# 保存文档的元数据
|
||||
doc = KBDocument(
|
||||
@@ -334,22 +394,47 @@ class KBHelper:
|
||||
chunk_count=len(chunks_text),
|
||||
media_count=0,
|
||||
)
|
||||
async with self.kb_db.get_db() as session:
|
||||
async with session.begin():
|
||||
session.add(doc)
|
||||
for media in saved_media:
|
||||
session.add(media)
|
||||
await session.commit()
|
||||
try:
|
||||
async with self.kb_db.get_db() as session:
|
||||
async with session.begin():
|
||||
session.add(doc)
|
||||
for media in saved_media:
|
||||
session.add(media)
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(doc)
|
||||
await session.refresh(doc)
|
||||
except KnowledgeBaseUploadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="metadata",
|
||||
user_message=(
|
||||
"元数据保存失败:文本块已写入知识库,但文档记录保存失败。"
|
||||
),
|
||||
details={"file_name": file_name, "doc_id": doc_id},
|
||||
) from exc
|
||||
|
||||
vec_db: FaissVecDB = self.vec_db # type: ignore
|
||||
await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db)
|
||||
await self.refresh_kb()
|
||||
await self.refresh_document(doc_id)
|
||||
try:
|
||||
await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db)
|
||||
await self.refresh_kb()
|
||||
await self.refresh_document(doc_id)
|
||||
except KnowledgeBaseUploadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise KnowledgeBaseUploadError(
|
||||
stage="metadata",
|
||||
user_message=(
|
||||
"元数据更新失败:文档已上传,但知识库统计信息刷新失败。"
|
||||
),
|
||||
details={"file_name": file_name, "doc_id": doc_id},
|
||||
) from exc
|
||||
return doc
|
||||
except Exception as e:
|
||||
logger.error(f"上传文档失败: {e}")
|
||||
if isinstance(e, KnowledgeBaseUploadError):
|
||||
logger.warning(f"上传文档失败: {e}", extra={"details": e.details})
|
||||
else:
|
||||
logger.error(f"上传文档失败: {e}", exc_info=True)
|
||||
# if file_path.exists():
|
||||
# file_path.unlink()
|
||||
|
||||
@@ -360,7 +445,7 @@ class KBHelper:
|
||||
except Exception as me:
|
||||
logger.warning(f"清理多媒体文件失败 {media_path}: {me}")
|
||||
|
||||
raise e
|
||||
raise
|
||||
|
||||
async def list_documents(
|
||||
self,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""检索模块"""
|
||||
|
||||
from .manager import RetrievalManager, RetrievalResult
|
||||
from .rank_fusion import FusedResult, RankFusion
|
||||
from .sparse_retriever import SparseResult, SparseRetriever
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import RetrievalManager, RetrievalResult
|
||||
from .rank_fusion import FusedResult, RankFusion
|
||||
from .sparse_retriever import SparseResult, SparseRetriever
|
||||
|
||||
__all__ = [
|
||||
"FusedResult",
|
||||
@@ -12,3 +15,31 @@ __all__ = [
|
||||
"SparseResult",
|
||||
"SparseRetriever",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in {"RetrievalManager", "RetrievalResult"}:
|
||||
from .manager import RetrievalManager, RetrievalResult
|
||||
|
||||
return {
|
||||
"RetrievalManager": RetrievalManager,
|
||||
"RetrievalResult": RetrievalResult,
|
||||
}[name]
|
||||
|
||||
if name in {"FusedResult", "RankFusion"}:
|
||||
from .rank_fusion import FusedResult, RankFusion
|
||||
|
||||
return {
|
||||
"FusedResult": FusedResult,
|
||||
"RankFusion": RankFusion,
|
||||
}[name]
|
||||
|
||||
if name in {"SparseResult", "SparseRetriever"}:
|
||||
from .sparse_retriever import SparseResult, SparseRetriever
|
||||
|
||||
return {
|
||||
"SparseResult": SparseResult,
|
||||
"SparseRetriever": SparseRetriever,
|
||||
}[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -8,10 +8,13 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import jieba
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
|
||||
from astrbot.core.knowledge_base.retrieval.tokenizer import (
|
||||
load_stopwords,
|
||||
tokenize_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
@@ -47,13 +50,9 @@ class SparseRetriever:
|
||||
self.kb_db = kb_db
|
||||
self._index_cache = {} # 缓存 BM25 索引
|
||||
|
||||
with open(
|
||||
self.hit_stopwords = load_stopwords(
|
||||
os.path.join(os.path.dirname(__file__), "hit_stopwords.txt"),
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
self.hit_stopwords = {
|
||||
word.strip() for word in set(f.read().splitlines()) if word.strip()
|
||||
}
|
||||
)
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
@@ -72,7 +71,52 @@ class SparseRetriever:
|
||||
List[SparseResult]: 检索结果列表
|
||||
|
||||
"""
|
||||
# 1. 获取所有相关块
|
||||
fts_results = []
|
||||
fallback_kb_ids = []
|
||||
query_tokens = tokenize_text(query, self.hit_stopwords)
|
||||
for kb_id in kb_ids:
|
||||
vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db")
|
||||
if not vec_db:
|
||||
continue
|
||||
top_k_sparse = kb_options.get(kb_id, {}).get("top_k_sparse", 50)
|
||||
result = await vec_db.document_storage.search_sparse(
|
||||
query_tokens=query_tokens,
|
||||
limit=top_k_sparse,
|
||||
)
|
||||
if result is None:
|
||||
fallback_kb_ids.append(kb_id)
|
||||
continue
|
||||
|
||||
for doc in result:
|
||||
chunk_md = json.loads(doc["metadata"])
|
||||
fts_results.append(
|
||||
SparseResult(
|
||||
chunk_id=doc["doc_id"],
|
||||
chunk_index=chunk_md["chunk_index"],
|
||||
doc_id=chunk_md["kb_doc_id"],
|
||||
kb_id=kb_id,
|
||||
content=doc["text"],
|
||||
score=-float(doc["score"]),
|
||||
),
|
||||
)
|
||||
|
||||
fallback_results = []
|
||||
if fallback_kb_ids:
|
||||
fallback_results = await self._retrieve_with_bm25(
|
||||
query=query,
|
||||
kb_ids=fallback_kb_ids,
|
||||
kb_options=kb_options,
|
||||
)
|
||||
results = fts_results + fallback_results
|
||||
results.sort(key=lambda x: x.score, reverse=True)
|
||||
return results
|
||||
|
||||
async def _retrieve_with_bm25(
|
||||
self,
|
||||
query: str,
|
||||
kb_ids: list[str],
|
||||
kb_options: dict,
|
||||
) -> list[SparseResult]:
|
||||
top_k_sparse = 0
|
||||
chunks = []
|
||||
for kb_id in kb_ids:
|
||||
@@ -103,20 +147,13 @@ class SparseRetriever:
|
||||
|
||||
# 2. 准备文档和索引
|
||||
corpus = [chunk["text"] for chunk in chunks]
|
||||
tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus]
|
||||
tokenized_corpus = [
|
||||
[word for word in doc if word not in self.hit_stopwords]
|
||||
for doc in tokenized_corpus
|
||||
]
|
||||
tokenized_corpus = [tokenize_text(doc, self.hit_stopwords) for doc in corpus]
|
||||
|
||||
# 3. 构建 BM25 索引
|
||||
bm25 = BM25Okapi(tokenized_corpus)
|
||||
|
||||
# 4. 执行检索
|
||||
tokenized_query = list(jieba.cut(query))
|
||||
tokenized_query = [
|
||||
word for word in tokenized_query if word not in self.hit_stopwords
|
||||
]
|
||||
tokenized_query = tokenize_text(query, self.hit_stopwords)
|
||||
scores = bm25.get_scores(tokenized_query)
|
||||
|
||||
# 5. 排序并返回 Top-K
|
||||
|
||||
39
astrbot/core/knowledge_base/retrieval/tokenizer.py
Normal file
39
astrbot/core/knowledge_base/retrieval/tokenizer.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Tokenization helpers shared by sparse retrieval indexes."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from re import Pattern
|
||||
|
||||
import jieba
|
||||
|
||||
_TERM_PATTERN: Pattern[str] = re.compile(r"\w", re.UNICODE)
|
||||
|
||||
|
||||
def load_stopwords(path: Path | str) -> set[str]:
|
||||
with Path(path).open(encoding="utf-8") as f:
|
||||
return {word.strip() for word in set(f.read().splitlines()) if word.strip()}
|
||||
|
||||
|
||||
def tokenize_text(text: str, stopwords: set[str]) -> list[str]:
|
||||
tokens = []
|
||||
for token in jieba.cut(text or ""):
|
||||
token = token.strip()
|
||||
if not token or token in stopwords:
|
||||
continue
|
||||
if not _TERM_PATTERN.search(token):
|
||||
continue
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def to_fts5_search_text(text: str, stopwords: set[str]) -> str:
|
||||
return " ".join(tokenize_text(text, stopwords))
|
||||
|
||||
|
||||
def quote_fts5_token(token: str) -> str:
|
||||
return '"' + token.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def build_fts5_or_query(tokens: list[str]) -> str:
|
||||
quoted_tokens = [quote_fts5_token(token) for token in tokens if token]
|
||||
return " OR ".join(quoted_tokens)
|
||||
@@ -63,6 +63,8 @@ class RateLimitStage(Stage):
|
||||
timestamps = self.event_timestamps[session_id]
|
||||
self._remove_expired_timestamps(timestamps, now)
|
||||
|
||||
if self.rate_limit_count <= 0:
|
||||
break
|
||||
if len(timestamps) < self.rate_limit_count:
|
||||
timestamps.append(now)
|
||||
break
|
||||
|
||||
@@ -13,12 +13,13 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.message.components import File, Record, Video
|
||||
from astrbot.core.message.components import BaseMessageComponent, File, Record, Video
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
from .kook_roles_record import KookRolesRecord
|
||||
from .kook_types import (
|
||||
ContainerModule,
|
||||
FileModule,
|
||||
@@ -27,14 +28,18 @@ from .kook_types import (
|
||||
KmarkdownElement,
|
||||
KookCardMessageContainer,
|
||||
KookChannelType,
|
||||
KookMarkdownMentionRolePart,
|
||||
KookMentionTagName,
|
||||
KookMessageEventData,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
KookRoleExtraType,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
)
|
||||
|
||||
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
|
||||
KOOK_AT_SELECTOR_REGEX = re.compile(r"\((met|rol)\)([^()]+)\(\1\)")
|
||||
AT_MENTION_PREFIX_REGEX = re.compile(r"^@[^\s]+(\s*-\s*[^\s]+)?\s*")
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
@@ -53,6 +58,7 @@ class KookPlatformAdapter(Platform):
|
||||
self._reconnect_task = None
|
||||
self.running = False
|
||||
self._main_task = None
|
||||
self._roles_cache = KookRolesRecord("", self.client.http_client)
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
@@ -84,7 +90,7 @@ class KookPlatformAdapter(Platform):
|
||||
event_type = event.type
|
||||
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
|
||||
if self._should_ignore_event_by_bot_nickname(event.author_id):
|
||||
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
|
||||
logger.debug("[KOOK] 判断此消息为来自机器人自身的消息, 忽略此消息")
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(event)
|
||||
@@ -92,8 +98,18 @@ class KookPlatformAdapter(Platform):
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
elif event_type == KookMessageType.SYSTEM:
|
||||
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
|
||||
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
|
||||
match event.extra.type:
|
||||
case KookRoleExtraType():
|
||||
# 此时 target_id 就是频道id(guild_id)
|
||||
guild_id = event.target_id
|
||||
logger.info(
|
||||
f'[KOOK] 收到频道"{guild_id}"的角色更新通知, 类型为"{event.extra.type.value}", 刷新角色id缓存'
|
||||
)
|
||||
self._roles_cache.clear_guild_roles_cache(int(guild_id))
|
||||
case _:
|
||||
logger.debug(
|
||||
f'[KOOK] 判断此消息为"{event.extra.type}"类型的系统通知, 因未实现此消息的处理流程而忽略此消息, 原始消息数据: {event.to_json()}'
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
@@ -124,6 +140,8 @@ class KookPlatformAdapter(Platform):
|
||||
logger.info("[KOOK] 尝试连接KOOK服务器...")
|
||||
|
||||
# 尝试连接
|
||||
await self.client.get_bot_info()
|
||||
self._roles_cache.set_bot_id(self.client.bot_id)
|
||||
success = await self.client.connect()
|
||||
|
||||
if success:
|
||||
@@ -191,47 +209,86 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: KookMessageEventData, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.extra.kmarkdown
|
||||
content = data.content or ""
|
||||
if kmarkdown is None:
|
||||
logger.error(
|
||||
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
|
||||
)
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
return [], ""
|
||||
async def _convert_text_message_to_component(
|
||||
self,
|
||||
content: str,
|
||||
raw_content: str,
|
||||
mention_role_part: list[KookMarkdownMentionRolePart] | None = None,
|
||||
guild_id: str | None = None,
|
||||
mention_name_map: dict[str, str] | None = None,
|
||||
) -> tuple[list[BaseMessageComponent], str]:
|
||||
# kook平台有一个角色(role)的概念,他表示拥有某一类权限的许多用户
|
||||
# 且角色本身也有一个自己的id,与正常用户id不同
|
||||
# 而在频道中是可以`@`角色的,而想要知道bot是否属于某个角色
|
||||
# 需要通过 `/user/view` 接口获取当前bot账号的某个频道下所属角色的id
|
||||
# 为了解决 https://github.com/AstrBotDevs/AstrBot/issues/7539
|
||||
# 在确定机器人需要响应某个`(rol)xxx(rol)`时,需要将角色id替换装当前的bot id
|
||||
# 包装成`At`机器人自己,而`At`的name就保留角色名称
|
||||
# 如果没有查询到角色id或者bot不属于某类角色, 则不处理此`(rol)xxx(rol)`
|
||||
# 暂时想不到能在不修改原有消息内容的情况下处理这个角色mention的方案
|
||||
|
||||
raw_content = kmarkdown.raw_content or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.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 = []
|
||||
message_str = raw_content
|
||||
bot_id = self.client.bot_id
|
||||
bot_nickname = self.client.bot_nickname
|
||||
bot_username = self.client.bot_username
|
||||
components: list[BaseMessageComponent] = []
|
||||
if mention_name_map is None:
|
||||
mention_name_map = {}
|
||||
cursor = 0
|
||||
|
||||
role_mention_counter = -1
|
||||
|
||||
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
plain_text = content[cursor : match.start()].strip(" ")
|
||||
if plain_text:
|
||||
components.append(Plain(text=plain_text))
|
||||
|
||||
mention_target = match.group(1).strip()
|
||||
if mention_target == "all":
|
||||
tag_name = match.group(1)
|
||||
mention_target = match.group(2).strip()
|
||||
if tag_name == KookMentionTagName.MENTION and mention_target == "all":
|
||||
components.append(AtAll())
|
||||
elif tag_name == KookMentionTagName.ROLE:
|
||||
role_mention_counter += 1
|
||||
role_id = 0
|
||||
role_mention_name = mention_target
|
||||
if mention_role_part is not None:
|
||||
if len(mention_role_part) > role_mention_counter:
|
||||
role_mention_name = mention_role_part[role_mention_counter].name
|
||||
role_id = mention_role_part[role_mention_counter].role_id
|
||||
if (
|
||||
bot_nickname == role_mention_name
|
||||
or bot_username == role_mention_name
|
||||
):
|
||||
components.append(
|
||||
At(
|
||||
qq=bot_id,
|
||||
name=role_mention_name, # 保留角色名称
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not mention_target.isdigit() and role_id == 0:
|
||||
continue
|
||||
|
||||
role_id = role_id or int(mention_target)
|
||||
if not guild_id:
|
||||
continue
|
||||
|
||||
if not guild_id.isdigit():
|
||||
continue
|
||||
|
||||
if not await self._roles_cache.has_role_in_channel(
|
||||
role_id, int(guild_id)
|
||||
):
|
||||
continue
|
||||
|
||||
components.append(
|
||||
At(
|
||||
qq=bot_id,
|
||||
name=role_mention_name, # 保留角色名称
|
||||
)
|
||||
)
|
||||
|
||||
elif mention_target:
|
||||
components.append(
|
||||
At(
|
||||
@@ -242,11 +299,11 @@ class KookPlatformAdapter(Platform):
|
||||
cursor = match.end()
|
||||
|
||||
if cursor < len(content):
|
||||
tail_text = content[cursor:]
|
||||
tail_text = content[cursor:].strip(" ")
|
||||
if tail_text:
|
||||
components.append(Plain(text=tail_text))
|
||||
|
||||
message_str = raw_content
|
||||
message_str = raw_content.strip()
|
||||
if components:
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
@@ -254,9 +311,8 @@ class KookPlatformAdapter(Platform):
|
||||
continue
|
||||
break
|
||||
if isinstance(comp, At):
|
||||
if str(comp.qq) == str(self_id):
|
||||
message_str = re.sub(
|
||||
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
|
||||
if str(comp.qq) == str(self.client.bot_id):
|
||||
message_str = AT_MENTION_PREFIX_REGEX.sub(
|
||||
"",
|
||||
message_str,
|
||||
count=1,
|
||||
@@ -270,10 +326,44 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
|
||||
async def _parse_kmarkdown_message(
|
||||
self, data: KookMessageEventData
|
||||
) -> tuple[list[BaseMessageComponent], str]:
|
||||
kmarkdown = data.extra.kmarkdown
|
||||
guild_id = data.extra.guild_id
|
||||
mention_role_part = None
|
||||
if kmarkdown:
|
||||
mention_role_part = kmarkdown.mention_role_part
|
||||
# 无法处理可能会收到的道具消息content,只能保留原样
|
||||
content = str(data.content) or ""
|
||||
if kmarkdown is None:
|
||||
logger.error(
|
||||
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
|
||||
)
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
return [], ""
|
||||
|
||||
raw_content = kmarkdown.raw_content or content
|
||||
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.mention_part
|
||||
for item in mention_part:
|
||||
mention_id = item.id
|
||||
if mention_id is None:
|
||||
continue
|
||||
mention_name_map[str(mention_id)] = str(item.username)
|
||||
|
||||
return await self._convert_text_message_to_component(
|
||||
content, raw_content, mention_role_part, guild_id, mention_name_map
|
||||
)
|
||||
|
||||
async def _parse_card_message(
|
||||
self, data: KookMessageEventData
|
||||
) -> tuple[list[BaseMessageComponent], str]:
|
||||
content = data.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
guild_id = data.extra.guild_id
|
||||
|
||||
card_list = KookCardMessageContainer.from_dict(json.loads(content))
|
||||
|
||||
@@ -304,18 +394,13 @@ class KookPlatformAdapter(Platform):
|
||||
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
message: list[BaseMessageComponent] = []
|
||||
|
||||
if text:
|
||||
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
|
||||
search_text = search.group(1).strip()
|
||||
if search_text == "all":
|
||||
message.append(AtAll())
|
||||
continue
|
||||
message.append(At(qq=search_text))
|
||||
text = text.replace(f"(met){search_text}(met)", "")
|
||||
|
||||
message.append(Plain(text=text))
|
||||
component_parts, text = await self._convert_text_message_to_component(
|
||||
text, text, guild_id=guild_id
|
||||
)
|
||||
message.extend(component_parts)
|
||||
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
@@ -387,12 +472,10 @@ class KookPlatformAdapter(Platform):
|
||||
abm.message_id = data.msg_id or "unknown"
|
||||
|
||||
if data.type == KookMessageType.KMARKDOWN:
|
||||
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
abm.message, abm.message_str = await self._parse_kmarkdown_message(data)
|
||||
elif data.type == KookMessageType.CARD:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
abm.message, abm.message_str = await self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
|
||||
@@ -3,6 +3,7 @@ import base64
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -59,12 +60,18 @@ class KookClient:
|
||||
|
||||
@property
|
||||
def bot_nickname(self):
|
||||
"""机器人昵称"""
|
||||
return self._bot_nickname
|
||||
|
||||
@property
|
||||
def bot_username(self):
|
||||
"""机器人名称"""
|
||||
return self._bot_username
|
||||
|
||||
@property
|
||||
def http_client(self):
|
||||
return self._http_client
|
||||
|
||||
async def get_bot_info(self) -> None:
|
||||
"""获取机器人账号信息"""
|
||||
url = KookApiPaths.USER_ME
|
||||
@@ -151,7 +158,6 @@ class KookClient:
|
||||
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
|
||||
@@ -215,8 +221,8 @@ class KookClient:
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[KOOK] WebSocket连接已关闭")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
except Exception:
|
||||
logger.error(f"[KOOK] 消息处理异常: {traceback.format_exc()}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
@@ -236,11 +242,15 @@ class KookClient:
|
||||
await self.event_callback(data)
|
||||
|
||||
case KookMessageSignal.HELLO:
|
||||
assert isinstance(data, KookHelloEventData)
|
||||
assert isinstance(data, KookHelloEventData), (
|
||||
f"期望 data 为 {KookHelloEventData.__name__}, 实际为 {type(data).__name__},"
|
||||
)
|
||||
await self._handle_hello(data)
|
||||
|
||||
case KookMessageSignal.RESUME_ACK:
|
||||
assert isinstance(data, KookResumeAckEventData)
|
||||
assert isinstance(data, KookResumeAckEventData), (
|
||||
f"期望 data 为 {KookResumeAckEventData.__name__}, 实际为 {type(data).__name__},"
|
||||
)
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
case KookMessageSignal.PONG:
|
||||
@@ -367,8 +377,8 @@ class KookClient:
|
||||
"type": kook_message_type,
|
||||
}
|
||||
if reply_message_id:
|
||||
payload["quote"] = reply_message_id
|
||||
payload["reply_msg_id"] = reply_message_id
|
||||
payload["quote"] = str(reply_message_id)
|
||||
payload["reply_msg_id"] = str(reply_message_id)
|
||||
|
||||
try:
|
||||
async with self._http_client.post(url, json=payload) as resp:
|
||||
|
||||
164
astrbot/core/platform/sources/kook/kook_roles_record.py
Normal file
164
astrbot/core/platform/sources/kook/kook_roles_record.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
import pydantic
|
||||
|
||||
from astrbot import logger
|
||||
|
||||
from .kook_types import KookApiPaths, KookUserViewResponse
|
||||
|
||||
USER_VIEW_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=3)
|
||||
ROLES_CACHE_MAX_SIZE = 2000
|
||||
MAX_RETRY_TIMES = 3
|
||||
RETRY_INTERVAL_SECOND = 1 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolesCache:
|
||||
value: set[int] | None = None
|
||||
failed_count: int = 0
|
||||
latest_update_time: float = 0
|
||||
|
||||
def update(self, roles: set[int] | None) -> None:
|
||||
if roles is not None:
|
||||
self.failed_count = 0
|
||||
self.value = roles
|
||||
self.latest_update_time = time.time()
|
||||
|
||||
def add_failed(self):
|
||||
self.failed_count += 1
|
||||
|
||||
def reset(self, without_value=False):
|
||||
if not without_value:
|
||||
self.value = None
|
||||
self.failed_count = 0
|
||||
self.latest_update_time = 0
|
||||
|
||||
|
||||
class KookRolesRecord:
|
||||
"""自动和缓存获取机器人所需响应的消息频道的role信息"""
|
||||
|
||||
def __init__(self, bot_id: str, http_client: aiohttp.ClientSession):
|
||||
# self._locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
self._lock = asyncio.Lock()
|
||||
self._bot_id = bot_id
|
||||
self._http_client = http_client
|
||||
# TODO 这个些配置后续加到适配器配置项里
|
||||
self._cache_max_size = ROLES_CACHE_MAX_SIZE
|
||||
self._max_retry_times = MAX_RETRY_TIMES
|
||||
self._retry_interval = RETRY_INTERVAL_SECOND
|
||||
self._roles_cache: OrderedDict[int, RolesCache] = OrderedDict()
|
||||
self._pending_tasks: dict[int, asyncio.Future] = {}
|
||||
|
||||
def set_bot_id(self, bot_id: str):
|
||||
self._bot_id = bot_id
|
||||
|
||||
def clear_guild_roles_cache(self, guild_id: int):
|
||||
self._roles_cache.pop(guild_id, None)
|
||||
self._pending_tasks.pop(guild_id, None)
|
||||
|
||||
async def _fetch_roles_by_guild_id(self, guild_id: int) -> set[int] | None:
|
||||
# 由于需要判断bot账号是属于某个角色(role)才会回复消息,
|
||||
# 而后续来自同一个频道的消息,在第一次查这个role的时候,
|
||||
# 会一直阻塞消息接收直到请求完成或者报错,
|
||||
# 所以,这里特意调低了timeout时间,避免阻塞太久
|
||||
url = KookApiPaths.USER_VIEW
|
||||
try:
|
||||
async with self._http_client.get(
|
||||
url,
|
||||
params={
|
||||
"guild_id": guild_id,
|
||||
"user_id": self._bot_id,
|
||||
},
|
||||
# TODO 这个超时时间后续加到适配器配置项里
|
||||
timeout=USER_VIEW_REQUEST_TIMEOUT,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败,状态码: {resp.status} , {await resp.text()}'
|
||||
)
|
||||
return
|
||||
try:
|
||||
resp_content = KookUserViewResponse.from_dict(await resp.json())
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(
|
||||
f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败, 响应数据格式错误: \n{e}'
|
||||
)
|
||||
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
|
||||
return
|
||||
|
||||
if not resp_content.success():
|
||||
logger.error(
|
||||
f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息失败: {resp_content.model_dump_json()}'
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f'[KOOK] 获取机器人在频道"{guild_id}"的角色id成功')
|
||||
return set(resp_content.data.roles)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息时请求异常: {e}'
|
||||
)
|
||||
return
|
||||
|
||||
async def has_role_in_channel(self, role_id: int, guild_id: int) -> bool:
|
||||
if (cache := self._roles_cache.get(guild_id)) is not None:
|
||||
self._roles_cache.move_to_end(guild_id)
|
||||
roles = cache.value
|
||||
if roles is not None:
|
||||
return role_id in roles
|
||||
|
||||
new_future: asyncio.Future[set[int] | None] = asyncio.Future()
|
||||
actual_future: asyncio.Future[set[int] | None] = self._pending_tasks.setdefault(
|
||||
guild_id, new_future
|
||||
)
|
||||
|
||||
if actual_future is not new_future:
|
||||
roles = await actual_future
|
||||
if roles is None:
|
||||
return False
|
||||
return role_id in roles
|
||||
|
||||
try:
|
||||
if (cache := self._roles_cache.get(guild_id)) is not None:
|
||||
if (
|
||||
cache.failed_count > self._max_retry_times
|
||||
and time.time() - cache.latest_update_time < self._retry_interval
|
||||
):
|
||||
new_future.set_result(None)
|
||||
return False
|
||||
|
||||
# 简单的容量控制 (LRU)
|
||||
if len(self._roles_cache) + 1 > self._cache_max_size:
|
||||
self._roles_cache.popitem(last=False)
|
||||
|
||||
roles_set = await self._fetch_roles_by_guild_id(guild_id)
|
||||
|
||||
cache = self._roles_cache.get(guild_id)
|
||||
if cache is not None:
|
||||
cache.update(roles_set)
|
||||
self._roles_cache.move_to_end(guild_id)
|
||||
else:
|
||||
cache = RolesCache(roles_set, latest_update_time=time.time())
|
||||
self._roles_cache[guild_id] = cache
|
||||
|
||||
result = False
|
||||
if roles_set is None:
|
||||
cache.add_failed()
|
||||
else:
|
||||
result = role_id in roles_set
|
||||
|
||||
new_future.set_result(roles_set)
|
||||
return result
|
||||
except Exception as e:
|
||||
new_future.set_result(None)
|
||||
logger.error(
|
||||
f'[KOOK] 获取机器人在频道"{guild_id}"的角色id信息时发生异常: {e}'
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
self._pending_tasks.pop(guild_id, None)
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from enum import Enum, IntEnum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
@@ -13,6 +13,7 @@ class KookApiPaths:
|
||||
|
||||
# 初始化相关
|
||||
USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me"
|
||||
USER_VIEW = f"{BASE_URL}{API_VERSION_PATH}/user/view"
|
||||
GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index"
|
||||
|
||||
# 消息相关
|
||||
@@ -23,6 +24,14 @@ class KookApiPaths:
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
class KookMentionTagName(str, Enum):
|
||||
"""用来匹配 `(tagName)value(tagName)` 格式里的tagName , 例如: `(met)all(met)`
|
||||
定义参见KMarkdown语法文档: https://developer.kookapp.cn/doc/kmarkdown"""
|
||||
|
||||
MENTION = "met"
|
||||
ROLE = "rol"
|
||||
|
||||
|
||||
class KookMessageType(IntEnum):
|
||||
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
@@ -56,6 +65,14 @@ class KookModuleType(str, Enum):
|
||||
CARD = "card"
|
||||
|
||||
|
||||
class KookRoleExtraType(str, Enum):
|
||||
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
ADDED_ROLE = "added_role"
|
||||
DELETED_ROLE = "deleted_role"
|
||||
UPDATED_ROLE = "updated_role"
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
@@ -67,7 +84,9 @@ SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookBaseDataClass(BaseModel):
|
||||
class KookBaseReceiveDataClass(BaseModel):
|
||||
"""接收数据基类,`to_dict`/`to_json`默认保证尽量json原样输出"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
arbitrary_types_allowed=True,
|
||||
@@ -84,11 +103,51 @@ class KookBaseDataClass(BaseModel):
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
mode: Literal["json", "python"] | str = "python",
|
||||
mode: Literal["json", "python"] | str = "json",
|
||||
by_alias=True,
|
||||
exclude_none=False,
|
||||
exclude_unset=True,
|
||||
) -> dict:
|
||||
"""默认配置预期场景为尽量原样输出,若需要使用此数据类发送json数据,
|
||||
请`exclude_none=True, exclude_unset=False`"""
|
||||
return self.model_dump(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
mode=mode,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
indent: int | None = None,
|
||||
ensure_ascii=False,
|
||||
by_alias=True,
|
||||
exclude_none=False,
|
||||
exclude_unset=True,
|
||||
) -> str:
|
||||
"""默认配置预期场景为尽量原样输出,若需要使用此数据类发送json数据,
|
||||
请`exclude_none=True, exclude_unset=False`"""
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
|
||||
class KookBaseSendDataClass(KookBaseReceiveDataClass):
|
||||
"""发送数据基类,`to_dict`/`to_json`保证默认输出内容格式包含接口格式所需最简格式内容"""
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
mode: Literal["json", "python"] | str = "json",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> dict:
|
||||
"""默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出,
|
||||
请`exclude_none=False, exclude_unset=True`"""
|
||||
return self.model_dump(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
@@ -104,6 +163,8 @@ class KookBaseDataClass(BaseModel):
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> str:
|
||||
"""默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出,
|
||||
请`exclude_none=False, exclude_unset=True`"""
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
@@ -113,7 +174,7 @@ class KookBaseDataClass(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class KookCardModelBase(KookBaseDataClass):
|
||||
class KookCardModelBase(KookBaseSendDataClass):
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
@@ -249,10 +310,28 @@ AnyModule = Annotated[
|
||||
]
|
||||
|
||||
|
||||
class KookCardMessage(KookBaseDataClass):
|
||||
class KookCardMessage(KookBaseSendDataClass):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
适用于发送单个卡片消息
|
||||
将此消息类型放入`Json`的data字段进行卡片消息发送,适配器会自动添加顶层的列表
|
||||
若要发送多个卡片消息,推荐使用KookCardMessageContainer进行卡片消息组装
|
||||
|
||||
使用方法:
|
||||
```python
|
||||
chain = []
|
||||
chain.append(
|
||||
Json(
|
||||
data=KookCardMessage(
|
||||
theme="info",
|
||||
size="lg",
|
||||
modules=[
|
||||
HeaderModule(text=PlainTextElement(content="test1")),
|
||||
],
|
||||
).to_dict()
|
||||
)
|
||||
)
|
||||
yield event.chain_result(chain)
|
||||
```
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
@@ -269,14 +348,71 @@ class KookCardMessage(KookBaseDataClass):
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
"""卡片消息容器(列表),可放入多个卡片消息(KookCardMessage)
|
||||
|
||||
使用方法:
|
||||
```python
|
||||
chain = []
|
||||
chain.append(
|
||||
Json(
|
||||
data=KookCardMessageContainer(
|
||||
[
|
||||
KookCardMessage(
|
||||
theme="info",
|
||||
size="lg",
|
||||
modules=[
|
||||
HeaderModule(text=PlainTextElement(content="test1")),
|
||||
],
|
||||
)
|
||||
]
|
||||
).to_dict()
|
||||
)
|
||||
)
|
||||
yield event.chain_result(chain)
|
||||
```
|
||||
"""
|
||||
|
||||
def append(self, object: KookCardMessage) -> None:
|
||||
return super().append(object)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str:
|
||||
def to_dict(
|
||||
self,
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> list[dict]:
|
||||
"""默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出,
|
||||
请`exclude_none=False, exclude_unset=True`"""
|
||||
return [
|
||||
i.to_dict(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
for i in self
|
||||
]
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
indent: int | None = None,
|
||||
ensure_ascii: bool = True,
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> str:
|
||||
"""默认配置预期场景为发送数据,若需要使用此数据类接收数据并尽量原样json输出,
|
||||
请`exclude_none=False, exclude_unset=True`"""
|
||||
return json.dumps(
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
[
|
||||
i.to_dict(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
for i in self
|
||||
],
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -293,7 +429,7 @@ class OrderMessage(BaseModel):
|
||||
|
||||
class KookMessageSignal(IntEnum):
|
||||
"""KOOK WebSocket 信令类型
|
||||
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
|
||||
ws文档: https://developer.kookapp.cn/doc/websocket"""
|
||||
|
||||
MESSAGE = 0
|
||||
"""server->client 消息(s包含聊天和通知消息)"""
|
||||
@@ -317,7 +453,7 @@ class KookChannelType(str, Enum):
|
||||
BROADCAST = "BROADCAST"
|
||||
|
||||
|
||||
class KookAuthor(KookBaseDataClass):
|
||||
class KookAuthor(KookBaseReceiveDataClass):
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
@@ -330,25 +466,110 @@ class KookAuthor(KookBaseDataClass):
|
||||
roles: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookKMarkdown(KookBaseDataClass):
|
||||
class KookMarkdownMentionPart(KookBaseReceiveDataClass):
|
||||
"""
|
||||
文档参考: https://developer.kookapp.cn/doc/event/message
|
||||
"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
full_name: str
|
||||
avatar: str
|
||||
|
||||
|
||||
class KookMarkdownMentionRolePart(KookBaseReceiveDataClass):
|
||||
"""
|
||||
文档参考: https://developer.kookapp.cn/doc/event/message
|
||||
"""
|
||||
|
||||
role_id: int
|
||||
name: str
|
||||
color: int
|
||||
color_type: int
|
||||
color_map: list[Any]
|
||||
position: int | None = None
|
||||
hoist: int | None = None
|
||||
mentionable: int | None = None
|
||||
permissions: int | None = None
|
||||
|
||||
|
||||
class KookKMarkdown(KookBaseReceiveDataClass):
|
||||
raw_content: str
|
||||
mention_part: list[Any] = Field(default_factory=list)
|
||||
mention_role_part: list[Any] = Field(default_factory=list)
|
||||
mention_part: list[KookMarkdownMentionPart] = Field(default_factory=list)
|
||||
mention_role_part: list[KookMarkdownMentionRolePart] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookExtra(KookBaseDataClass):
|
||||
type: int | str
|
||||
class KookRole(KookBaseReceiveDataClass):
|
||||
"""服务器角色对象数据结构"""
|
||||
|
||||
role_id: int = Field(alias="role_id")
|
||||
name: str | None = None
|
||||
color: int | None = None
|
||||
position: int | None = None
|
||||
hoist: int | None = 0 # 是否在成员列表中单独展示
|
||||
mentionable: int | None = 0 # 是否允许所有人提到该角色
|
||||
permissions: int | None = None
|
||||
|
||||
|
||||
class KookRoleEventBody(KookBaseReceiveDataClass):
|
||||
"""
|
||||
服务器角色相关事件 (added_role, updated_role, deleted_role) 的 Body 部分
|
||||
文档参考: https://developer.kookapp.cn/doc/event/guild-role
|
||||
"""
|
||||
|
||||
role_id: int | None = None # 在 deleted_role 中通常只给 ID
|
||||
name: str | None = None
|
||||
color: int | None = None
|
||||
position: int | None = None
|
||||
hoist: int | None = None
|
||||
mentionable: int | None = None
|
||||
permissions: int | None = None
|
||||
# 有些事件会将完整的 role 对象包裹在 body 里
|
||||
# 如果是 added_role 且需要处理更完整的结构,可以扩展
|
||||
|
||||
|
||||
class KookExtra(KookBaseReceiveDataClass):
|
||||
"""事件结构定义
|
||||
文档参考 : https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
type: KookRoleExtraType | str | int
|
||||
"""当 type 非系统消息(255)时, type为int
|
||||
|
||||
当 type 为系统消息(255)时, type为str
|
||||
"""
|
||||
|
||||
code: str | None = None
|
||||
body: dict[str, Any] | None = None
|
||||
body: KookRole | dict[str, Any] | None = None
|
||||
author: KookAuthor | None = None
|
||||
kmarkdown: KookKMarkdown | None = None
|
||||
last_msg_content: str | None = None
|
||||
mention: list[str] = Field(default_factory=list)
|
||||
mention_all: bool = False
|
||||
mention_here: bool = False
|
||||
guild_id: str | None = None
|
||||
guild_type: int | None = None
|
||||
channel_name: str | None = None
|
||||
visible_only: str | None = None
|
||||
mention_no_at: list | None = None
|
||||
mention_roles: list[int] | None = None
|
||||
nav_channels: list | None = None
|
||||
emoji: list | None = None
|
||||
preview_content: str | None = None
|
||||
channel_type: int | None = None
|
||||
send_msg_device: int | None = None
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def parse_type(cls, value):
|
||||
"""优先尝试匹配枚举,失败则保留原值"""
|
||||
if isinstance(value, str):
|
||||
if value in {e.value for e in KookRoleExtraType}:
|
||||
return KookRoleExtraType(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class KookMessageEventData(KookBaseDataClass):
|
||||
class KookMessageEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.MESSAGE] = Field(
|
||||
KookMessageSignal.MESSAGE, exclude=True
|
||||
)
|
||||
@@ -358,7 +579,7 @@ class KookMessageEventData(KookBaseDataClass):
|
||||
type: KookMessageType
|
||||
target_id: str
|
||||
author_id: str
|
||||
content: str | dict[str, Any]
|
||||
content: str | dict[str, Any] # 道具消息时这里是dict
|
||||
msg_id: str
|
||||
msg_timestamp: int
|
||||
nonce: str
|
||||
@@ -366,7 +587,7 @@ class KookMessageEventData(KookBaseDataClass):
|
||||
extra: KookExtra
|
||||
|
||||
|
||||
class KookHelloEventData(KookBaseDataClass):
|
||||
class KookHelloEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.HELLO] = Field(
|
||||
KookMessageSignal.HELLO, exclude=True
|
||||
)
|
||||
@@ -376,28 +597,28 @@ class KookHelloEventData(KookBaseDataClass):
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookPingEventData(KookBaseDataClass):
|
||||
class KookPingEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.PING] = Field(
|
||||
KookMessageSignal.PING, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookPongEventData(KookBaseDataClass):
|
||||
class KookPongEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.PONG] = Field(
|
||||
KookMessageSignal.PONG, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookResumeEventData(KookBaseDataClass):
|
||||
class KookResumeEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME] = Field(
|
||||
KookMessageSignal.RESUME, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookReconnectEventData(KookBaseDataClass):
|
||||
class KookReconnectEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.RECONNECT] = Field(
|
||||
KookMessageSignal.RECONNECT, exclude=True
|
||||
)
|
||||
@@ -407,7 +628,7 @@ class KookReconnectEventData(KookBaseDataClass):
|
||||
err: str
|
||||
|
||||
|
||||
class KookResumeAckEventData(KookBaseDataClass):
|
||||
class KookResumeAckEventData(KookBaseReceiveDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
|
||||
KookMessageSignal.RESUME_ACK, exclude=True
|
||||
)
|
||||
@@ -416,7 +637,7 @@ class KookResumeAckEventData(KookBaseDataClass):
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookWebsocketEvent(KookBaseDataClass):
|
||||
class KookWebsocketEvent(KookBaseReceiveDataClass):
|
||||
"""KOOK WebSocket 原始推送结构"""
|
||||
|
||||
signal: KookMessageSignal = Field(
|
||||
@@ -451,22 +672,22 @@ class KookWebsocketEvent(KookBaseDataClass):
|
||||
return data
|
||||
|
||||
|
||||
class KookUserTag(KookBaseDataClass):
|
||||
class KookUserTag(KookBaseReceiveDataClass):
|
||||
color: str
|
||||
bg_color: str
|
||||
text: str
|
||||
|
||||
|
||||
class KookApiResponseBase(KookBaseDataClass):
|
||||
class KookApiResponseBase(KookBaseReceiveDataClass):
|
||||
code: int
|
||||
message: str
|
||||
data: Any
|
||||
data: dict # 就算请求失败了也是空dict
|
||||
|
||||
def success(self) -> bool:
|
||||
return self.code == 0
|
||||
|
||||
|
||||
class KookUserMeData(KookBaseDataClass):
|
||||
class KookUserMeData(KookBaseReceiveDataClass):
|
||||
"""USER_ME 接口返回的 'data' 字段主体"""
|
||||
|
||||
id: str
|
||||
@@ -495,7 +716,47 @@ class KookUserMeResponse(KookApiResponseBase):
|
||||
data: KookUserMeData
|
||||
|
||||
|
||||
class KookGatewayIndexData(KookBaseDataClass):
|
||||
class KookUserMeViewData(KookBaseReceiveDataClass):
|
||||
"""USER_ME 接口返回的 'data' 字段主体"""
|
||||
|
||||
class KookTagInfo(KookBaseReceiveDataClass):
|
||||
color: str
|
||||
bg_color: str
|
||||
text: str
|
||||
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
online: bool
|
||||
os: str
|
||||
status: int
|
||||
avatar: str
|
||||
vip_avatar: str
|
||||
banner: str
|
||||
nickname: str
|
||||
roles: list[int]
|
||||
is_vip: bool
|
||||
vip_amp: bool
|
||||
bot: bool
|
||||
kpm_vip: str | None = None
|
||||
wealth_level: int
|
||||
bot_status: int
|
||||
tag_info: KookTagInfo
|
||||
mobile_verified: bool
|
||||
is_sys: bool
|
||||
client_id: str
|
||||
verified: bool
|
||||
joined_at: int
|
||||
active_time: int
|
||||
|
||||
|
||||
class KookUserViewResponse(KookApiResponseBase):
|
||||
"""USER_VIEW 完整响应结构"""
|
||||
|
||||
data: KookUserMeViewData
|
||||
|
||||
|
||||
class KookGatewayIndexData(KookBaseReceiveDataClass):
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from contextlib import suppress
|
||||
from typing import cast
|
||||
|
||||
from apscheduler.events import EVENT_JOB_ERROR
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ChatType
|
||||
@@ -80,6 +81,15 @@ class TelegramPlatformAdapter(Platform):
|
||||
self.last_command_hash = None
|
||||
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.scheduler.add_listener(
|
||||
lambda ev: logger.error(
|
||||
"Scheduled job %s raised: %s",
|
||||
ev.job_id,
|
||||
ev.exception,
|
||||
exc_info=ev.exception,
|
||||
),
|
||||
EVENT_JOB_ERROR,
|
||||
)
|
||||
self._terminating = False
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._polling_recovery_requested = asyncio.Event()
|
||||
@@ -695,31 +705,36 @@ class TelegramPlatformAdapter(Platform):
|
||||
f"Processing media group {media_group_id}, total {len(updates_and_contexts)} items"
|
||||
)
|
||||
|
||||
# Use the first update to create the base message (with reply, caption, etc.)
|
||||
first_update, first_context = updates_and_contexts[0]
|
||||
abm = await self.convert_message(first_update, first_context)
|
||||
try:
|
||||
# Use the first update to create the base message (with reply, caption, etc.)
|
||||
first_update, first_context = updates_and_contexts[0]
|
||||
abm = await self.convert_message(first_update, first_context)
|
||||
|
||||
if not abm:
|
||||
logger.warning(
|
||||
f"Failed to convert the first message of media group {media_group_id}"
|
||||
if not abm:
|
||||
logger.warning(
|
||||
f"Failed to convert the first message of media group {media_group_id}"
|
||||
)
|
||||
return
|
||||
|
||||
# Add additional media from remaining updates by reusing convert_message
|
||||
for update, context in updates_and_contexts[1:]:
|
||||
# Convert the message but skip reply chains (get_reply=False)
|
||||
extra = await self.convert_message(update, context, get_reply=False)
|
||||
if not extra:
|
||||
continue
|
||||
|
||||
# Merge only the message components (keep base session/meta from first)
|
||||
abm.message.extend(extra.message)
|
||||
logger.debug(
|
||||
f"Added {len(extra.message)} components to media group {media_group_id}"
|
||||
)
|
||||
|
||||
# Process the merged message
|
||||
await self.handle_msg(abm)
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Failed to process media group {media_group_id}", exc_info=True
|
||||
)
|
||||
return
|
||||
|
||||
# Add additional media from remaining updates by reusing convert_message
|
||||
for update, context in updates_and_contexts[1:]:
|
||||
# Convert the message but skip reply chains (get_reply=False)
|
||||
extra = await self.convert_message(update, context, get_reply=False)
|
||||
if not extra:
|
||||
continue
|
||||
|
||||
# Merge only the message components (keep base session/meta from first)
|
||||
abm.message.extend(extra.message)
|
||||
logger.debug(
|
||||
f"Added {len(extra.message)} components to media group {media_group_id}"
|
||||
)
|
||||
|
||||
# Process the merged message
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
||||
message_event = TelegramPlatformEvent(
|
||||
|
||||
@@ -7,6 +7,7 @@ import io
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -149,6 +150,7 @@ class WeixinOCAdapter(Platform):
|
||||
self._sync_buf = ""
|
||||
self._qr_expired_count = 0
|
||||
self._context_tokens: dict[str, str] = {}
|
||||
self._context_tokens_dirty = False
|
||||
self._typing_states: dict[str, TypingSessionState] = {}
|
||||
self._last_inbound_error = ""
|
||||
self._recent_message_cache_size = self._get_int_config(
|
||||
@@ -538,12 +540,29 @@ class WeixinOCAdapter(Platform):
|
||||
saved_base = str(self.config.get("weixin_oc_base_url", "")).strip()
|
||||
if saved_base:
|
||||
self.base_url = saved_base.rstrip("/")
|
||||
raw_context_tokens = self.config.get("weixin_oc_context_tokens", {})
|
||||
if isinstance(raw_context_tokens, dict):
|
||||
self._context_tokens = self._normalize_context_tokens(raw_context_tokens)
|
||||
|
||||
def _normalize_context_tokens(
|
||||
self, raw_context_tokens: Mapping[object, object]
|
||||
) -> dict[str, str]:
|
||||
normalized_context_tokens: dict[str, str] = {}
|
||||
for user_id, context_token in raw_context_tokens.items():
|
||||
normalized_user_id = str(user_id).strip()
|
||||
normalized_context_token = str(context_token).strip()
|
||||
if not normalized_user_id or not normalized_context_token:
|
||||
continue
|
||||
normalized_context_tokens[normalized_user_id] = normalized_context_token
|
||||
return normalized_context_tokens
|
||||
|
||||
async def _save_account_state(self) -> None:
|
||||
normalized_context_tokens = self._normalize_context_tokens(self._context_tokens)
|
||||
self.config["weixin_oc_token"] = self.token or ""
|
||||
self.config["weixin_oc_account_id"] = self.account_id or ""
|
||||
self.config["weixin_oc_sync_buf"] = self._sync_buf
|
||||
self.config["weixin_oc_base_url"] = self.base_url
|
||||
self.config["weixin_oc_context_tokens"] = normalized_context_tokens
|
||||
|
||||
for platform in astrbot_config.get("platform", []):
|
||||
if not isinstance(platform, dict):
|
||||
@@ -556,10 +575,12 @@ class WeixinOCAdapter(Platform):
|
||||
platform["weixin_oc_account_id"] = self.account_id or ""
|
||||
platform["weixin_oc_sync_buf"] = self._sync_buf
|
||||
platform["weixin_oc_base_url"] = self.base_url
|
||||
platform["weixin_oc_context_tokens"] = normalized_context_tokens
|
||||
break
|
||||
|
||||
self._sync_client_state()
|
||||
astrbot_config.save_config()
|
||||
self._context_tokens_dirty = False
|
||||
|
||||
def _is_login_session_valid(
|
||||
self, login_session: OpenClawLoginSession | None
|
||||
@@ -1445,7 +1466,10 @@ class WeixinOCAdapter(Platform):
|
||||
|
||||
context_token = str(msg.get("context_token", "")).strip()
|
||||
if context_token:
|
||||
self._context_tokens[from_user_id] = context_token
|
||||
previous_context_token = self._context_tokens.get(from_user_id)
|
||||
if previous_context_token != context_token:
|
||||
self._context_tokens[from_user_id] = context_token
|
||||
self._context_tokens_dirty = True
|
||||
|
||||
item_list = cast(list[dict[str, Any]], msg.get("item_list", []))
|
||||
reply_component = None
|
||||
@@ -1539,9 +1563,10 @@ class WeixinOCAdapter(Platform):
|
||||
)
|
||||
return
|
||||
|
||||
should_save_state = self._context_tokens_dirty
|
||||
if data.get("get_updates_buf"):
|
||||
self._sync_buf = str(data.get("get_updates_buf"))
|
||||
await self._save_account_state()
|
||||
should_save_state = True
|
||||
|
||||
for msg in data.get("msgs", []) if isinstance(data.get("msgs"), list) else []:
|
||||
if self._shutdown_event.is_set():
|
||||
@@ -1549,6 +1574,8 @@ class WeixinOCAdapter(Platform):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
await self._handle_inbound_message(msg)
|
||||
if should_save_state:
|
||||
await self._save_account_state()
|
||||
|
||||
def _message_chain_to_text(self, message_chain: MessageChain) -> str:
|
||||
text = ""
|
||||
|
||||
@@ -363,6 +363,10 @@ class ProviderManager:
|
||||
)
|
||||
case "longcat_chat_completion":
|
||||
from .sources.longcat_source import ProviderLongCat as ProviderLongCat
|
||||
case "minimax_token_plan":
|
||||
from .sources.minimax_token_plan_source import (
|
||||
ProviderMiniMaxTokenPlan as ProviderMiniMaxTokenPlan,
|
||||
)
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
|
||||
@@ -293,7 +293,7 @@ class ProviderAnthropic(Provider):
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
payloads["max_tokens"] = 65536
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
try:
|
||||
@@ -389,7 +389,7 @@ class ProviderAnthropic(Provider):
|
||||
reasoning_signature = ""
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
payloads["max_tokens"] = 65536
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
async with self.client.messages.stream(
|
||||
|
||||
55
astrbot/core/provider/sources/minimax_token_plan_source.py
Normal file
55
astrbot/core/provider/sources/minimax_token_plan_source.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
MINIMAX_TOKEN_PLAN_MODELS = [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
]
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"minimax_token_plan",
|
||||
"MiniMax Token Plan Provider Adapter",
|
||||
)
|
||||
class ProviderMiniMaxTokenPlan(ProviderAnthropic):
|
||||
"""MiniMax Token Plan provider.
|
||||
|
||||
The Token Plan API does not support the /models endpoint, so get_models()
|
||||
returns a hard-coded model list. This is a Token Plan API limitation.
|
||||
See https://github.com/AstrBotDevs/AstrBot/issues/7585 for details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config,
|
||||
provider_settings,
|
||||
) -> None:
|
||||
# Keep api_base fixed; Token Plan users do not need to configure it.
|
||||
provider_config["api_base"] = "https://api.minimaxi.com/anthropic"
|
||||
# MiniMax Token Plan requires the Authorization: Bearer <token> header.
|
||||
key = provider_config.get("key", "")
|
||||
actual_key = key[0] if isinstance(key, list) else key
|
||||
provider_config.setdefault("custom_headers", {})["Authorization"] = (
|
||||
f"Bearer {actual_key}"
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
provider_config,
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
configured_model = provider_config.get("model", "MiniMax-M2.7")
|
||||
if configured_model not in MINIMAX_TOKEN_PLAN_MODELS:
|
||||
raise ValueError(
|
||||
f"Unsupported model: {configured_model!r}. "
|
||||
f"Supported models: {', '.join(MINIMAX_TOKEN_PLAN_MODELS)}"
|
||||
)
|
||||
|
||||
self.set_model(configured_model)
|
||||
|
||||
async def get_models(self) -> list[str]:
|
||||
"""Return the hard-coded known model list because Token Plan cannot fetch it dynamically."""
|
||||
return MINIMAX_TOKEN_PLAN_MODELS.copy()
|
||||
@@ -854,24 +854,26 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 工具集未提供
|
||||
# Should be unreachable
|
||||
raise Exception("工具集未提供")
|
||||
for tool in tools.func_list:
|
||||
if (
|
||||
tool_call.type == "function"
|
||||
and tool.name == tool_call.function.name
|
||||
):
|
||||
# workaround for #1454
|
||||
if isinstance(tool_call.function.arguments, str):
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
else:
|
||||
args = tool_call.function.arguments
|
||||
args_ls.append(args)
|
||||
func_name_ls.append(tool_call.function.name)
|
||||
tool_call_ids.append(tool_call.id)
|
||||
|
||||
# gemini-2.5 / gemini-3 series extra_content handling
|
||||
extra_content = getattr(tool_call, "extra_content", None)
|
||||
if extra_content is not None:
|
||||
tool_call_extra_content_dict[tool_call.id] = extra_content
|
||||
if tool_call.type == "function":
|
||||
# workaround for #1454
|
||||
if isinstance(tool_call.function.arguments, str):
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析参数失败: {e}")
|
||||
args = {}
|
||||
else:
|
||||
args = tool_call.function.arguments
|
||||
args_ls.append(args)
|
||||
func_name_ls.append(tool_call.function.name)
|
||||
tool_call_ids.append(tool_call.id)
|
||||
|
||||
# gemini-2.5 / gemini-3 series extra_content handling
|
||||
extra_content = getattr(tool_call, "extra_content", None)
|
||||
if extra_content is not None:
|
||||
tool_call_extra_content_dict[tool_call.id] = extra_content
|
||||
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args = args_ls
|
||||
llm_response.tools_call_name = func_name_ls
|
||||
|
||||
@@ -7,6 +7,8 @@ from .star_handler import (
|
||||
register_custom_filter,
|
||||
register_event_message_type,
|
||||
register_llm_tool,
|
||||
register_on_agent_begin,
|
||||
register_on_agent_done,
|
||||
register_on_astrbot_loaded,
|
||||
register_on_decorating_result,
|
||||
register_on_llm_request,
|
||||
@@ -31,6 +33,8 @@ __all__ = [
|
||||
"register_custom_filter",
|
||||
"register_event_message_type",
|
||||
"register_llm_tool",
|
||||
"register_on_agent_begin",
|
||||
"register_on_agent_done",
|
||||
"register_on_astrbot_loaded",
|
||||
"register_on_decorating_result",
|
||||
"register_on_llm_request",
|
||||
|
||||
@@ -460,6 +460,64 @@ def register_on_llm_response(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_agent_begin(**kwargs):
|
||||
"""当 Agent 开始运行时的事件
|
||||
|
||||
Examples:
|
||||
```py
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@on_agent_begin()
|
||||
async def test(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
请务必接收两个参数:event, run_context
|
||||
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnAgentBeginEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_agent_done(**kwargs):
|
||||
"""当 Agent 运行完成后的事件
|
||||
|
||||
Examples:
|
||||
```py
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.api.provider import LLMResponse
|
||||
|
||||
@on_agent_done()
|
||||
async def test(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
response: LLMResponse,
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
请务必接收三个参数:event, run_context, response
|
||||
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnAgentDoneEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_using_llm_tool(**kwargs):
|
||||
"""当调用函数工具前的事件。
|
||||
会传入 tool 和 tool_args 参数。
|
||||
|
||||
@@ -71,6 +71,22 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnAgentBeginEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnAgentDoneEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
@@ -213,6 +229,8 @@ class EventType(enum.Enum):
|
||||
OnWaitingLLMRequestEvent = enum.auto() # 等待调用 LLM(在获取锁之前,仅通知)
|
||||
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
|
||||
OnLLMResponseEvent = enum.auto() # LLM 响应后
|
||||
OnAgentBeginEvent = enum.auto() # Agent 开始运行
|
||||
OnAgentDoneEvent = enum.auto() # Agent 运行完成
|
||||
OnDecoratingResultEvent = enum.auto() # 发送消息前
|
||||
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
|
||||
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
||||
|
||||
@@ -11,8 +11,8 @@ from ..updator import RepoZipUpdator
|
||||
|
||||
|
||||
class PluginUpdator(RepoZipUpdator):
|
||||
def __init__(self, repo_mirror: str = "") -> None:
|
||||
super().__init__(repo_mirror)
|
||||
def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None:
|
||||
super().__init__(repo_mirror, verify=verify)
|
||||
self.plugin_store_path = get_astrbot_plugin_path()
|
||||
|
||||
def get_plugin_store_path(self) -> str:
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic.dataclasses import dataclass
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.cron.manager import CronJobSchedulingError
|
||||
from astrbot.core.tools.registry import builtin_tool
|
||||
|
||||
_CRON_TOOL_CONFIG = {
|
||||
@@ -112,14 +113,17 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
|
||||
"origin": "tool",
|
||||
}
|
||||
|
||||
job = await cron_mgr.add_active_job(
|
||||
name=name,
|
||||
cron_expression=str(cron_expression) if cron_expression else None,
|
||||
payload=payload,
|
||||
description=note,
|
||||
run_once=run_once,
|
||||
run_at=run_at_dt,
|
||||
)
|
||||
try:
|
||||
job = await cron_mgr.add_active_job(
|
||||
name=name,
|
||||
cron_expression=str(cron_expression) if cron_expression else None,
|
||||
payload=payload,
|
||||
description=note,
|
||||
run_once=run_once,
|
||||
run_at=run_at_dt,
|
||||
)
|
||||
except CronJobSchedulingError:
|
||||
return "error: failed to schedule task due to invalid configuration."
|
||||
next_run = job.next_run_time or run_at_dt
|
||||
suffix = (
|
||||
f"one-time at {next_run}"
|
||||
@@ -195,7 +199,10 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
|
||||
updates["cron_expression"] = cron_expression
|
||||
updates["payload"] = payload
|
||||
|
||||
job = await cron_mgr.update_job(str(job_id), **updates)
|
||||
try:
|
||||
job = await cron_mgr.update_job(str(job_id), **updates)
|
||||
except CronJobSchedulingError:
|
||||
return "error: failed to update task due to invalid configuration."
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
return f"Updated future task {job.job_id} ({job.name})."
|
||||
|
||||
@@ -77,7 +77,21 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
async def _resolve_path_from_sandbox(
|
||||
self, context: ContextWrapper[AstrAgentContext], path: str
|
||||
) -> tuple[str, bool]:
|
||||
if os.path.exists(path):
|
||||
# if the path is relative, check if the file exists in user's local workspace
|
||||
if not os.path.isabs(path):
|
||||
unified_msg_origin = context.context.event.unified_msg_origin
|
||||
if unified_msg_origin:
|
||||
from astrbot.core.tools.computer_tools.util import workspace_root
|
||||
|
||||
try:
|
||||
ws_path = workspace_root(unified_msg_origin)
|
||||
ws_candidate = (ws_path / path).resolve()
|
||||
if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path):
|
||||
return str(ws_candidate), False
|
||||
except Exception:
|
||||
pass
|
||||
# check if the file exists in local environment (only allow absolute paths to prevent traversal)
|
||||
elif os.path.isfile(path):
|
||||
return path, False
|
||||
|
||||
try:
|
||||
|
||||
@@ -197,6 +197,10 @@ async def _bocha_search(
|
||||
header = {
|
||||
"Authorization": f"Bearer {bocha_key}",
|
||||
"Content-Type": "application/json",
|
||||
# Explicitly disable brotli encoding to avoid aiohttp >= 3.13.3 brotli
|
||||
# decompression incompatibility (TypeError: process() takes exactly 1 argument).
|
||||
# See: https://github.com/aio-libs/aiohttp/issues/11898
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
|
||||
@@ -7,7 +7,6 @@ import psutil
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
from .zip_updator import ReleaseInfo, RepoZipUpdator
|
||||
|
||||
@@ -18,8 +17,8 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
功能包括检查更新、下载更新文件、解压缩更新文件等
|
||||
"""
|
||||
|
||||
def __init__(self, repo_mirror: str = "") -> None:
|
||||
super().__init__(repo_mirror)
|
||||
def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None:
|
||||
super().__init__(repo_mirror, verify=verify)
|
||||
self.MAIN_PATH = get_astrbot_path()
|
||||
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
|
||||
|
||||
@@ -176,7 +175,7 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
file_url = f"{proxy}/{file_url}"
|
||||
|
||||
try:
|
||||
await download_file(file_url, "temp.zip")
|
||||
await self._download_file(file_url, "temp.zip")
|
||||
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
|
||||
self.unzip_file("temp.zip", self.MAIN_PATH)
|
||||
except BaseException as e:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -16,6 +19,31 @@ ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_shiki_runtime() -> str:
|
||||
runtime_path = (
|
||||
Path(__file__).resolve().parent / "template" / "shiki_runtime.iife.js"
|
||||
)
|
||||
if not runtime_path.exists():
|
||||
logger.error(
|
||||
"T2I Shiki runtime not found at %s. Run `cd dashboard && pnpm run build:t2i-shiki-runtime` to regenerate it. Continuing without code highlighting.",
|
||||
runtime_path,
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
runtime = runtime_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
logger.warning(
|
||||
"Failed to load T2I Shiki runtime from %s: %s. Continuing without code highlighting.",
|
||||
runtime_path,
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
|
||||
return runtime.replace("</script", "<\\/script")
|
||||
|
||||
|
||||
class NetworkRenderStrategy(RenderStrategy):
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
super().__init__()
|
||||
@@ -77,6 +105,7 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
if options:
|
||||
default_options |= options
|
||||
|
||||
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
|
||||
post_data = {
|
||||
"tmpl": tmpl_str,
|
||||
"json": return_url,
|
||||
@@ -129,9 +158,9 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
if not template_name:
|
||||
template_name = "base"
|
||||
tmpl_str = await self.get_template(name=template_name)
|
||||
text = text.replace("`", "\\`")
|
||||
text_base64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
return await self.render_custom_template(
|
||||
tmpl_str,
|
||||
{"text": text, "version": f"v{VERSION}"},
|
||||
{"text_base64": text_base64, "version": f"v{VERSION}"},
|
||||
return_url,
|
||||
)
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Astrbot PowerShell {{ version }} </title>
|
||||
<title>Astrbot PowerShell {{ version }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/common.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
|
||||
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #010409;
|
||||
--text-color: #e6edf3;
|
||||
--title-bar-color: #161b22;
|
||||
--title-text-color: #e6edf3;
|
||||
--font-family: 'Consolas', 'Microsoft YaHei Mono', 'Dengxian Mono', 'Courier New', monospace;
|
||||
--font-family: "Consolas", "Microsoft YaHei Mono", "Dengxian Mono", "Courier New", monospace;
|
||||
--glow-color: rgba(200, 220, 255, 0.7);
|
||||
}
|
||||
|
||||
@@ -36,7 +31,6 @@
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 18px;
|
||||
/* The CRT glow effect from the image */
|
||||
text-shadow: 0 0 15px var(--glow-color), 0 0 7px rgba(255, 255, 255, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -63,9 +57,9 @@
|
||||
color: var(--title-text-color);
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
text-shadow: none; /* No glow for title bar */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
.header .title {
|
||||
font-weight: bold;
|
||||
font-size: 28px;
|
||||
@@ -78,13 +72,10 @@
|
||||
|
||||
main {
|
||||
padding: 1rem 1.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#content {
|
||||
/* min-width and max-width removed as per request */
|
||||
}
|
||||
|
||||
/* --- Markdown Styles adjusted for terminal look --- */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.4;
|
||||
margin-top: 20px;
|
||||
@@ -144,7 +135,16 @@
|
||||
font-size: 100%;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
text-shadow: none; /* Disable glow inside code blocks for clarity */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
pre.shiki {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
pre.shiki > code,
|
||||
pre.shiki span {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -165,9 +165,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<span class="title">> Astrbot PowerShell</span>
|
||||
<span class="title">> Astrbot PowerShell</span>
|
||||
<span class="version">{{ version }}</span>
|
||||
</div>
|
||||
|
||||
@@ -175,10 +174,45 @@
|
||||
<div id="content"></div>
|
||||
</main>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe }}`);
|
||||
</script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
|
||||
contentElement.innerHTML = marked.parse(source);
|
||||
|
||||
if (window.AstrBotT2IShiki) {
|
||||
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-dark");
|
||||
}
|
||||
|
||||
if (window.renderMathInElement) {
|
||||
window.renderMathInElement(contentElement, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
552
astrbot/core/utils/t2i/template/astrbot_vitepress.html
Normal file
552
astrbot/core/utils/t2i/template/astrbot_vitepress.html
Normal file
@@ -0,0 +1,552 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>AstrBot Docs {{ version }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
|
||||
<style>
|
||||
:root {
|
||||
--vp-c-bg: #1b1b1f;
|
||||
--vp-c-bg-soft: #202127;
|
||||
--vp-c-bg-alt: #161618;
|
||||
--vp-c-bg-elv: #202127;
|
||||
--vp-c-border: #3c3f44;
|
||||
--vp-c-divider: #2e2e32;
|
||||
--vp-c-gutter: #000000;
|
||||
--vp-c-text-1: #dfdfd6;
|
||||
--vp-c-text-2: #98989f;
|
||||
--vp-c-text-3: #6a6a71;
|
||||
--vp-c-brand-1: #a8b1ff;
|
||||
--vp-c-brand-2: #5c73e7;
|
||||
--vp-c-brand-3: #3e63dd;
|
||||
--vp-c-brand-soft: rgba(100, 108, 255, 0.16);
|
||||
--vp-c-default-soft: rgba(101, 117, 133, 0.16);
|
||||
--vp-code-bg: var(--vp-c-default-soft);
|
||||
--vp-code-block-bg: var(--vp-c-bg-alt);
|
||||
--vp-code-line-height: 1.7;
|
||||
--vp-code-font-size: 0.875em;
|
||||
--vp-shadow-2: 0 3px 12px rgba(0, 0, 0, 0.07), 0 1px 4px rgba(0, 0, 0, 0.07);
|
||||
--vp-shadow-3: 0 12px 32px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
--vp-layout-max-width: 1440px;
|
||||
--vp-nav-height: 64px;
|
||||
--vp-radius: 12px;
|
||||
--vp-font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--vp-font-family-cjk: "Inter4CJK", -apple-system, BlinkMacSystemFont, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--vp-code-font-family: ui-monospace, "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family);
|
||||
background: linear-gradient(180deg, rgba(27, 27, 31, 0.96), rgba(27, 27, 31, 1));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body:lang(zh),
|
||||
body:lang(ja) {
|
||||
font-family: var(--vp-font-family-cjk);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.25s, opacity 0.25s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: var(--vp-layout-max-width);
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vp-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--vp-nav-height);
|
||||
padding: 0 32px;
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(27, 27, 31, 0.9);
|
||||
border-bottom: 1px solid var(--vp-c-gutter);
|
||||
}
|
||||
|
||||
.vp-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vp-brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 6px 16px rgba(62, 99, 221, 0.24));
|
||||
}
|
||||
|
||||
.vp-brand-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vp-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vp-search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vp-search kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vp-shell {
|
||||
padding: 42px 32px 56px;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.vp-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vp-content-frame {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vp-hero {
|
||||
display: block;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.vp-hero.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vp-hero-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 48px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vp-hero-copy p {
|
||||
max-width: 720px;
|
||||
margin: 16px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.78;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.vp-doc {
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.vp-doc > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3,
|
||||
.vp-doc h4 {
|
||||
position: relative;
|
||||
scroll-margin-top: 100px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.vp-doc h2 {
|
||||
margin: 48px 0 16px;
|
||||
padding-top: 24px;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.vp-doc h3 {
|
||||
margin: 32px 0 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.vp-doc p,
|
||||
.vp-doc ul,
|
||||
.vp-doc ol,
|
||||
.vp-doc table,
|
||||
.vp-doc blockquote,
|
||||
.vp-doc [class*="language-"],
|
||||
.vp-doc .math {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vp-doc strong {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vp-doc code {
|
||||
font-family: var(--vp-code-font-family);
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"],
|
||||
.vp-block {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--vp-code-block-bg);
|
||||
transition: background-color 0.5s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] > span.lang,
|
||||
.vp-block > span.lang {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
transition: color 0.4s, opacity 0.4s;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] pre.shiki,
|
||||
.vp-doc [class*="language-"] pre,
|
||||
.vp-block pre.shiki,
|
||||
.vp-block pre {
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
background: var(--vp-code-block-bg) !important;
|
||||
line-height: var(--vp-code-line-height);
|
||||
font-size: var(--vp-code-font-size);
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] code,
|
||||
.vp-block code {
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] pre.shiki code,
|
||||
.vp-doc [class*="language-"] pre code,
|
||||
.vp-block pre.shiki code,
|
||||
.vp-block pre code {
|
||||
font-family: var(--vp-code-font-family);
|
||||
display: block;
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] pre.shiki,
|
||||
.vp-block pre.shiki {
|
||||
color: var(--shiki-dark, #e1e4e8) !important;
|
||||
background-color: var(--shiki-dark-bg, var(--vp-code-block-bg)) !important;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] pre.shiki > code,
|
||||
.vp-doc [class*="language-"] pre.shiki span,
|
||||
.vp-block pre.shiki > code,
|
||||
.vp-block pre.shiki span {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.vp-doc blockquote {
|
||||
margin: 16px 0;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.vp-doc blockquote p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.vp-doc hr {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
margin: 38px 0;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.vp-doc img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
margin: 22px auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
}
|
||||
|
||||
.vp-doc ul,
|
||||
.vp-doc ol {
|
||||
padding-left: 1.35rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.vp-doc li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.vp-doc thead {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.vp-doc th,
|
||||
.vp-doc td {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vp-doc tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.vp-footer {
|
||||
height: 72px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 72px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 14px;
|
||||
border-top: 1px solid var(--vp-c-gutter);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.vp-shell {
|
||||
padding-inline: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="vp-nav">
|
||||
<div class="vp-brand">
|
||||
<img class="vp-brand-logo" src="https://cf.s3.soulter.top/astrbot-logo.svg" alt="AstrBot logo" />
|
||||
<div class="vp-brand-name">AstrBot</div>
|
||||
</div>
|
||||
<div class="vp-nav-actions">
|
||||
<span>{{ version }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="vp-shell">
|
||||
<main class="vp-main">
|
||||
<div class="vp-content-frame">
|
||||
<div class="vp-hero is-hidden" id="heroBlock">
|
||||
<div class="vp-hero-copy">
|
||||
<h1 id="heroTitle">AstrBot Docs</h1>
|
||||
<p id="heroLead">将长文本内容整理为单页文档,参考 VitePress 默认主题的深色配色、正文排版与代码块节奏,适合技术说明与发布页。</p>
|
||||
</div>
|
||||
</div>
|
||||
<article class="vp-doc" id="content"></article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="vp-footer">Rendered by AstrBot {{ version }}</footer>
|
||||
</div>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
contentElement.innerHTML = marked.parse(source);
|
||||
|
||||
assignHeadingIds(contentElement);
|
||||
enhanceCodeBlocks(contentElement);
|
||||
|
||||
if (window.renderMathInElement) {
|
||||
window.renderMathInElement(contentElement, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const headings = collectHeadings(contentElement);
|
||||
populateHero(contentElement, headings);
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function assignHeadingIds(root) {
|
||||
Array.from(root.querySelectorAll("h1, h2, h3")).forEach((heading, index) => {
|
||||
heading.id = `section-${index + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
function collectHeadings(root) {
|
||||
return Array.from(root.querySelectorAll("h1, h2, h3")).map((heading) => ({
|
||||
element: heading,
|
||||
id: heading.id,
|
||||
level: Number(heading.tagName.slice(1)),
|
||||
text: heading.textContent.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
function populateHero(root, headings) {
|
||||
const heroBlock = document.getElementById("heroBlock");
|
||||
const heroTitle = document.getElementById("heroTitle");
|
||||
const heroLead = document.getElementById("heroLead");
|
||||
const firstHeading = headings.find((heading) => heading.level === 1) || headings[0];
|
||||
|
||||
if (!firstHeading) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroBlock.classList.remove("is-hidden");
|
||||
|
||||
const leadParagraph = firstHeading.element.nextElementSibling;
|
||||
const title = firstHeading.text;
|
||||
|
||||
heroTitle.textContent = title;
|
||||
|
||||
if (leadParagraph && leadParagraph.tagName === "P") {
|
||||
heroLead.textContent = leadParagraph.textContent.trim();
|
||||
leadParagraph.remove();
|
||||
}
|
||||
|
||||
if (firstHeading.element.parentElement === root) {
|
||||
firstHeading.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function extractLanguage(codeElement) {
|
||||
const className = codeElement.className || "";
|
||||
const match = className.match(/language-([\\w+#.-]+)/i);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function enhanceCodeBlocks(root) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre > code")).map((codeElement) => ({
|
||||
rawLanguage: extractLanguage(codeElement),
|
||||
displayLanguage: extractLanguage(codeElement).trim().split(/\\s+/, 1)[0].toLowerCase(),
|
||||
}));
|
||||
|
||||
if (window.AstrBotT2IShiki) {
|
||||
window.AstrBotT2IShiki.highlightAllCodeBlocks(root, "github-dark");
|
||||
}
|
||||
|
||||
Array.from(root.querySelectorAll("pre")).forEach((preElement, index) => {
|
||||
if (preElement.parentElement && preElement.parentElement.classList.contains("vp-code-block")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = blocks[index] || { displayLanguage: "" };
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = `language-${block.displayLanguage || "text"}`;
|
||||
|
||||
if (block.displayLanguage) {
|
||||
wrapper.innerHTML = `<span class="lang">${escapeHtml(block.displayLanguage)}</span>`;
|
||||
}
|
||||
|
||||
preElement.replaceWith(wrapper);
|
||||
wrapper.appendChild(preElement);
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,246 +2,285 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>AstrBot {{ version }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/path/to/styles/default.min.css">
|
||||
<script src="/path/to/highlight.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
|
||||
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
|
||||
<style>
|
||||
#content {
|
||||
min-width: 200px;
|
||||
max-width: 85%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1em 1em;
|
||||
}
|
||||
|
||||
body {
|
||||
word-break: break-word;
|
||||
line-height: 1.75;
|
||||
font-weight: 400;
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.5;
|
||||
margin-top: 35px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
|
||||
margin-top: -1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
content: "#";
|
||||
display: inline-block;
|
||||
color: #3eaf7c;
|
||||
padding-right: 0.23em;
|
||||
}
|
||||
h1 {
|
||||
position: relative;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
h1::before {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
h2 {
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 2.2rem;
|
||||
border-bottom: 1px solid #ececec;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
margin-top: 5px;
|
||||
}
|
||||
p {
|
||||
line-height: inherit;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
strong {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
margin: auto;
|
||||
border: 3px solid rgba(62, 175, 124, 0.2);
|
||||
}
|
||||
hr {
|
||||
border-top: 1px solid #3eaf7c;
|
||||
border-bottom: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
code {
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin: 0;
|
||||
color: #3eaf7c;
|
||||
font-size: 0.85em;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
line-height: 1.75;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #3eaf7c;
|
||||
}
|
||||
pre > code {
|
||||
font-size: 12px;
|
||||
padding: 15px 12px;
|
||||
margin: 0;
|
||||
word-break: normal;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
color: #333;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
pre.shiki {
|
||||
padding: 15px 12px;
|
||||
}
|
||||
pre.shiki > code {
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: #3eaf7c;
|
||||
}
|
||||
a:hover, a:active {
|
||||
border-bottom: 1.5px solid #3eaf7c;
|
||||
}
|
||||
a:before {
|
||||
content: "⇲";
|
||||
}
|
||||
table {
|
||||
display: inline-block !important;
|
||||
font-size: 12px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border: solid 1px #3eaf7c;
|
||||
}
|
||||
thead {
|
||||
background: #3eaf7c;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
tr:nth-child(2n) {
|
||||
background-color: rgba(62, 175, 124, 0.2);
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 7px;
|
||||
line-height: 24px;
|
||||
}
|
||||
td {
|
||||
min-width: 120px;
|
||||
}
|
||||
blockquote {
|
||||
color: #666;
|
||||
padding: 1px 23px;
|
||||
margin: 22px 0;
|
||||
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
|
||||
border-color: #42b983;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
blockquote::after {
|
||||
display: block;
|
||||
content: "";
|
||||
}
|
||||
blockquote > p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
details {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-left: 4px solid #3eaf7c;
|
||||
padding-left: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: white;
|
||||
margin: 0 -17px;
|
||||
}
|
||||
details summary::-webkit-details-marker {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
ol, ul {
|
||||
padding-left: 28px;
|
||||
}
|
||||
ol li, ul li {
|
||||
margin-bottom: 0;
|
||||
list-style: inherit;
|
||||
}
|
||||
ol li .task-list-item, ul li .task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
|
||||
margin-top: 0;
|
||||
}
|
||||
ol ul, ul ul, ol ol, ul ol {
|
||||
margin-top: 3px;
|
||||
}
|
||||
ol li {
|
||||
padding-left: 6px;
|
||||
}
|
||||
ol li::marker {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
ul li {
|
||||
list-style: none;
|
||||
}
|
||||
ul li:before {
|
||||
content: "•";
|
||||
margin-right: 4px;
|
||||
color: #3eaf7c;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="background-color: #3276dc; color: #fff; font-size: 64px; ">
|
||||
<div style="background-color: #3276dc; color: #fff; font-size: 64px;">
|
||||
<span style="font-weight: bold; margin-left: 16px"># AstrBot</span>
|
||||
<span>{{ version }}</span>
|
||||
</div>
|
||||
<article style="margin-top: 32px" id="content"></article>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe}}`);
|
||||
</script>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
|
||||
contentElement.innerHTML = marked.parse(source);
|
||||
|
||||
if (window.AstrBotT2IShiki) {
|
||||
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-light");
|
||||
}
|
||||
|
||||
if (window.renderMathInElement) {
|
||||
window.renderMathInElement(contentElement, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "$", right: "$", display: false }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<style>
|
||||
#content {
|
||||
min-width: 200px;
|
||||
max-width: 85%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1em 1em;
|
||||
}
|
||||
|
||||
body {
|
||||
word-break: break-word;
|
||||
line-height: 1.75;
|
||||
font-weight: 400;
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
color: #333;
|
||||
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.5;
|
||||
margin-top: 35px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
|
||||
margin-top: -1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
content: "#";
|
||||
display: inline-block;
|
||||
color: #3eaf7c;
|
||||
padding-right: 0.23em;
|
||||
}
|
||||
h1 {
|
||||
position: relative;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
h1::before {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
h2 {
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 2.2rem;
|
||||
border-bottom: 1px solid #ececec;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
margin-top: 5px;
|
||||
}
|
||||
p {
|
||||
line-height: inherit;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
strong {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 2px;
|
||||
display: block;
|
||||
margin: auto;
|
||||
border: 3px solid rgba(62, 175, 124, 0.2);
|
||||
}
|
||||
hr {
|
||||
border-top: 1px solid #3eaf7c;
|
||||
border-bottom: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
code {
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin: 0;
|
||||
color: #3eaf7c;
|
||||
font-size: 0.85em;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
line-height: 1.75;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #3eaf7c;
|
||||
}
|
||||
pre > code {
|
||||
font-size: 12px;
|
||||
padding: 15px 12px;
|
||||
margin: 0;
|
||||
word-break: normal;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
color: #333;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
a {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: #3eaf7c;
|
||||
}
|
||||
a:hover, a:active {
|
||||
border-bottom: 1.5px solid #3eaf7c;
|
||||
}
|
||||
a:before {
|
||||
content: "⇲";
|
||||
}
|
||||
table {
|
||||
display: inline-block !important;
|
||||
font-size: 12px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border: solid 1px #3eaf7c;
|
||||
}
|
||||
thead {
|
||||
background: #3eaf7c;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
tr:nth-child(2n) {
|
||||
background-color: rgba(62, 175, 124, 0.2);
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 7px;
|
||||
line-height: 24px;
|
||||
}
|
||||
td {
|
||||
min-width: 120px;
|
||||
}
|
||||
blockquote {
|
||||
color: #666;
|
||||
padding: 1px 23px;
|
||||
margin: 22px 0;
|
||||
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
|
||||
border-color: #42b983;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
blockquote::after {
|
||||
display: block;
|
||||
content: "";
|
||||
}
|
||||
blockquote > p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
details {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-left: 4px solid #3eaf7c;
|
||||
padding-left: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: white;
|
||||
margin: 0px -17px;
|
||||
}
|
||||
details summary::-webkit-details-marker {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
ol, ul {
|
||||
padding-left: 28px;
|
||||
}
|
||||
ol li, ul li {
|
||||
margin-bottom: 0;
|
||||
list-style: inherit;
|
||||
}
|
||||
ol li .task-list-item, ul li .task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
|
||||
margin-top: 0;
|
||||
}
|
||||
ol ul, ul ul, ol ol, ul ol {
|
||||
margin-top: 3px;
|
||||
}
|
||||
ol li {
|
||||
padding-left: 6px;
|
||||
}
|
||||
ol li::marker {
|
||||
color: #3eaf7c;
|
||||
}
|
||||
ul li {
|
||||
list-style: none;
|
||||
}
|
||||
ul li:before {
|
||||
content: "•";
|
||||
margin-right: 4px;
|
||||
color: #3eaf7c;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
152
astrbot/core/utils/t2i/template/shiki_runtime.iife.js
Normal file
152
astrbot/core/utils/t2i/template/shiki_runtime.iife.js
Normal file
File diff suppressed because one or more lines are too long
@@ -12,7 +12,11 @@ class TemplateManager:
|
||||
所有创建、更新、删除操作仅影响用户目录,以确保更新框架时用户数据安全。
|
||||
"""
|
||||
|
||||
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
|
||||
CORE_TEMPLATES = [
|
||||
"base.html",
|
||||
"astrbot_powershell.html",
|
||||
"astrbot_vitepress.html",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.builtin_template_dir = os.path.join(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.io import download_file, on_error
|
||||
from astrbot.core.utils.io import on_error
|
||||
from astrbot.core.utils.version_comparator import VersionComparator
|
||||
|
||||
|
||||
@@ -33,36 +33,53 @@ class ReleaseInfo:
|
||||
|
||||
|
||||
class RepoZipUpdator:
|
||||
def __init__(self, repo_mirror: str = "") -> None:
|
||||
def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None:
|
||||
self.repo_mirror = repo_mirror
|
||||
self.rm_on_error = on_error
|
||||
self.httpx_verify = certifi.where() if verify is None else verify
|
||||
|
||||
def _create_httpx_client(self, timeout: float = 30.0) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=timeout,
|
||||
trust_env=True,
|
||||
verify=self.httpx_verify,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _truncate_response_body(body: str, max_len: int = 1000) -> str:
|
||||
if len(body) <= max_len:
|
||||
return body
|
||||
return body[:max_len] + "...[truncated]"
|
||||
|
||||
async def _download_file(
|
||||
self, url: str, path: str, timeout: float = 1800.0
|
||||
) -> None:
|
||||
target_path = Path(path)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with self._create_httpx_client(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
with target_path.open("wb") as file:
|
||||
async for chunk in response.aiter_bytes(8192):
|
||||
file.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"下载文件失败: {url} -> {target_path}, 错误: {e}")
|
||||
if self.rm_on_error and target_path.exists():
|
||||
target_path.unlink()
|
||||
raise
|
||||
|
||||
async def fetch_release_info(self, url: str, latest: bool = True) -> list:
|
||||
"""请求版本信息。
|
||||
返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。
|
||||
"""
|
||||
try:
|
||||
ssl_context = ssl.create_default_context(
|
||||
cafile=certifi.where(),
|
||||
) # 新增:创建基于 certifi 的 SSL 上下文
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=ssl_context,
|
||||
) # 新增:使用 TCPConnector 指定 SSL 上下文
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
connector=connector,
|
||||
) as session,
|
||||
session.get(url) as response,
|
||||
):
|
||||
# 检查 HTTP 状态码
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
logger.error(
|
||||
f"请求 {url} 失败,状态码: {response.status}, 内容: {text}",
|
||||
)
|
||||
raise Exception(f"请求失败,状态码: {response.status}")
|
||||
result = await response.json()
|
||||
async with self._create_httpx_client() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if not result:
|
||||
return []
|
||||
# if latest:
|
||||
@@ -80,9 +97,17 @@ class RepoZipUpdator:
|
||||
"zipball_url": release["zipball_url"],
|
||||
},
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
response_body = ""
|
||||
if e.response is not None:
|
||||
response_body = self._truncate_response_body(e.response.text)
|
||||
logger.error(
|
||||
f"请求 {url} 失败,状态码: {e.response.status_code}, 内容: {response_body}",
|
||||
)
|
||||
raise Exception("解析版本信息失败") from e
|
||||
except Exception as e:
|
||||
logger.error(f"解析版本信息时发生异常: {e}")
|
||||
raise Exception("解析版本信息失败")
|
||||
raise Exception("解析版本信息失败") from e
|
||||
return ret
|
||||
|
||||
def github_api_release_parser(self, releases: list) -> list:
|
||||
@@ -186,7 +211,7 @@ class RepoZipUpdator:
|
||||
f"检查到设置了镜像站,将使用镜像站下载 {author}/{repo} 仓库源码: {release_url}",
|
||||
)
|
||||
|
||||
await download_file(release_url, target_path + ".zip")
|
||||
await self._download_file(release_url, target_path + ".zip")
|
||||
|
||||
def parse_github_url(self, url: str):
|
||||
"""使用正则表达式解析 GitHub 仓库 URL,支持 `.git` 后缀和 `tree/branch` 结构
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import jsonify, request
|
||||
|
||||
@@ -26,8 +26,12 @@ class CronRoute(Route):
|
||||
def _serialize_job(self, job) -> dict:
|
||||
data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__
|
||||
for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]:
|
||||
if isinstance(data.get(k), datetime):
|
||||
data[k] = data[k].isoformat()
|
||||
v = data.get(k)
|
||||
if isinstance(v, datetime):
|
||||
# Attach UTC
|
||||
if v.tzinfo is None:
|
||||
v = v.replace(tzinfo=timezone.utc)
|
||||
data[k] = v.isoformat()
|
||||
# expose note explicitly for UI (prefer payload.note then description)
|
||||
payload = data.get("payload") or {}
|
||||
data["note"] = payload.get("note") or data.get("description") or ""
|
||||
|
||||
@@ -128,6 +128,13 @@ class KnowledgeBaseRoute(Route):
|
||||
|
||||
return _callback
|
||||
|
||||
@staticmethod
|
||||
def _format_failed_doc_error(file_name: str, error: Exception) -> str:
|
||||
message = str(error).strip() or "上传失败:发生未知错误。"
|
||||
if message.startswith(file_name):
|
||||
return message
|
||||
return f"{file_name}: {message}"
|
||||
|
||||
async def _background_upload_task(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -189,7 +196,12 @@ class KnowledgeBaseRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"上传文档 {file_info['file_name']} 失败: {e}")
|
||||
failed_docs.append(
|
||||
{"file_name": file_info["file_name"], "error": str(e)},
|
||||
{
|
||||
"file_name": file_info["file_name"],
|
||||
"error": self._format_failed_doc_error(
|
||||
file_info["file_name"], e
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# 更新任务完成状态
|
||||
@@ -276,7 +288,10 @@ class KnowledgeBaseRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"导入文档 {file_name} 失败: {e}")
|
||||
failed_docs.append(
|
||||
{"file_name": file_name, "error": str(e)},
|
||||
{
|
||||
"file_name": file_name,
|
||||
"error": self._format_failed_doc_error(file_name, e),
|
||||
},
|
||||
)
|
||||
|
||||
# 更新任务完成状态
|
||||
|
||||
@@ -79,6 +79,8 @@ class PluginRoute(Route):
|
||||
EventType.AdapterMessageEvent: "平台消息下发时",
|
||||
EventType.OnLLMRequestEvent: "LLM 请求时",
|
||||
EventType.OnLLMResponseEvent: "LLM 响应后",
|
||||
EventType.OnAgentBeginEvent: "Agent 开始运行时",
|
||||
EventType.OnAgentDoneEvent: "Agent 运行完成后",
|
||||
EventType.OnDecoratingResultEvent: "回复消息前",
|
||||
EventType.OnCallingFuncToolEvent: "函数工具",
|
||||
EventType.OnAfterMessageSentEvent: "发送消息后",
|
||||
|
||||
29
changelogs/v4.23.1.md
Normal file
29
changelogs/v4.23.1.md
Normal file
@@ -0,0 +1,29 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
*hotfix of v4.23.0*
|
||||
|
||||
- 将 `python-ripgrep` 依赖降级到 `0.0.8`,修复 Python 3.13, 3.14 版本无法正常启动的问题。([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514))
|
||||
- 修复会话 ID 包含冒号 `:` 时,平台路由在 Dashboard 中无法显示的问题。([#7517](https://github.com/AstrBotDevs/AstrBot/pull/7517))
|
||||
- 适配 DeerFlow 2.0,更新 DeerFlow Runner、API Client、会话命令、Provider 管理逻辑、配置文档与相关测试。([#7500](https://github.com/AstrBotDevs/AstrBot/pull/7500))
|
||||
- 移除 Dashboard `v-main` 不必要的 margin,额外的外边距会把背景色显露出来,因此视觉上看起来像右侧多了一条边框。([#7481](https://github.com/AstrBotDevs/AstrBot/pull/7481))
|
||||
- 修复插件安装状态检查时格式不一致导致的判断问题。([#7493](https://github.com/AstrBotDevs/AstrBot/pull/7493))
|
||||
- Dashboard 代码块高亮切换到 Shiki,并同步暗色/亮色主题渲染,优化 README、更新日志与聊天消息中的代码块显示效果。([#7497](https://github.com/AstrBotDevs/AstrBot/pull/7497))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
*hotfix of v4.23.0*
|
||||
|
||||
- Fixed inconsistent format handling when checking whether a plugin is installed. ([#7493](https://github.com/AstrBotDevs/AstrBot/pull/7493))
|
||||
- Downgraded `python-ripgrep` to `0.0.8` to fix dependency compatibility issues. ([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514))
|
||||
- Fixed platform routes not being displayed in Dashboard when the session ID contains a colon `:`. ([#7517](https://github.com/AstrBotDevs/AstrBot/pull/7517))
|
||||
- Switched Dashboard code-block highlighting to Shiki, synchronized dark/light theme rendering, and improved code-block display in README, changelog, and chat messages. ([#7497](https://github.com/AstrBotDevs/AstrBot/pull/7497))
|
||||
- Aligned the DeerFlow runner with DeerFlow 2.0, including updates to the runner, API client, conversation commands, provider management, documentation, and tests. ([#7500](https://github.com/AstrBotDevs/AstrBot/pull/7500))
|
||||
- Removed unnecessary `v-main` margins in Dashboard for more consistent layout across viewports. ([#7481](https://github.com/AstrBotDevs/AstrBot/pull/7481))
|
||||
- Updated the smoke test workflow to cover multiple operating systems and Python versions, and added a startup check script. ([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514))
|
||||
69
changelogs/v4.23.2.md
Normal file
69
changelogs/v4.23.2.md
Normal file
@@ -0,0 +1,69 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 知识库稀疏检索阶段新增 SQLite FTS5 支持,大幅优化万到十万级别分块时造成的召回时的显著卡顿。([#7648](https://github.com/AstrBotDevs/AstrBot/pull/7648))
|
||||
- KOOK 平台新增角色提及支持,包含角色记录、事件解析、API 类型与相关测试。([#7626](https://github.com/AstrBotDevs/AstrBot/pull/7626))
|
||||
- 新增 MiniMax Token Plan Provider。([#7609](https://github.com/AstrBotDevs/AstrBot/pull/7609))
|
||||
- 新增 `on_agent_begin`、`on_using_llm_tool`、`on_llm_tool_respond`、`on_agent_done` 插件事件钩子,并更新插件开发文档。([#7540](https://github.com/AstrBotDevs/AstrBot/pull/7540))
|
||||
- 为 t2i 模板启用 Shiki 代码高亮,并新增 VitePress 风格模板。([#7501](https://github.com/AstrBotDevs/AstrBot/pull/7501))
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化 `EmptyModelOutputError` 处理,不再严格强制校验模型工具输出的名字是否符合工具集,采用引导的方式让模型输出正确的工具调用。([#7375](https://github.com/AstrBotDevs/AstrBot/pull/7375))
|
||||
- 优化 `SendMessageToUserTool` 工具在本地 workspace root 内解析相对文件路径的问题。([#7668](https://github.com/AstrBotDevs/AstrBot/pull/7668))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Bocha 搜索返回 Brotli 压缩内容时出现 `Can not decode content-encoding: br` 的问题。([#7655](https://github.com/AstrBotDevs/AstrBot/pull/7655))
|
||||
- 修复微信个人号主动 Cron 发送时 `context_token` 未持久化的问题。([#7595](https://github.com/AstrBotDevs/AstrBot/pull/7595))
|
||||
- 修复 Anthropic Provider 默认 `max_tokens` 偏小导致工具调用输出文件操作时造成大量截断的问题。([#7593](https://github.com/AstrBotDevs/AstrBot/pull/7593))
|
||||
- 修复 Cron `last_run_at` 在 Dashboard 中未按本地时区显示的问题。([#7625](https://github.com/AstrBotDevs/AstrBot/pull/7625))
|
||||
- 修复 Cron 工具调度失败时静默继续的问题,并在失败时返回明确错误给模型。([#7513](https://github.com/AstrBotDevs/AstrBot/pull/7513))
|
||||
- 修复 Telegram 媒体组异常被静默吞掉的问题。([#7537](https://github.com/AstrBotDevs/AstrBot/pull/7537))
|
||||
- 修复会话限流计数为 0 时可能触发 `IndexError` 的问题。([#7635](https://github.com/AstrBotDevs/AstrBot/pull/7635))
|
||||
|
||||
- 修复知识库上传错误处理与错误提示,提升失败时的可诊断性。([#7534](https://github.com/AstrBotDevs/AstrBot/pull/7534), [#7536](https://github.com/AstrBotDevs/AstrBot/pull/7536))
|
||||
- 修复 Dashboard 聊天附件 401 问题。([#7569](https://github.com/AstrBotDevs/AstrBot/pull/7569))
|
||||
- 修复 Dashboard 数字输入框在未编辑时失焦会重置为 0 的问题。([#7560](https://github.com/AstrBotDevs/AstrBot/pull/7560))
|
||||
- 修复 Dashboard 列表项内代码块暗色模式传递问题,并移除 `ThemeAwareMarkdownCodeBlock` 中注入的默认值。([#7667](https://github.com/AstrBotDevs/AstrBot/pull/7667), [commit](https://github.com/AstrBotDevs/AstrBot/commit/fd2ca702d))
|
||||
- 修复 Updater 请求不支持 SOCKS 代理的问题,并补充相关测试。([#7615](https://github.com/AstrBotDevs/AstrBot/pull/7615))
|
||||
- 回滚 SCSS import warning 修复以避免相关兼容问题。([#7616](https://github.com/AstrBotDevs/AstrBot/pull/7616))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added SQLite FTS5 support to the knowledge-base sparse retrieval stage, greatly reducing recall latency for knowledge bases with tens of thousands to hundreds of thousands of chunks. ([#7648](https://github.com/AstrBotDevs/AstrBot/pull/7648))
|
||||
- Added KOOK role mention support, including role records, event parsing, API types, and tests. ([#7626](https://github.com/AstrBotDevs/AstrBot/pull/7626))
|
||||
- Added the MiniMax Token Plan Provider. ([#7609](https://github.com/AstrBotDevs/AstrBot/pull/7609))
|
||||
- Added plugin event hooks for `on_agent_begin`, `on_using_llm_tool`, `on_llm_tool_respond`, and `on_agent_done`, with updated plugin development docs. ([#7540](https://github.com/AstrBotDevs/AstrBot/pull/7540))
|
||||
- Enabled Shiki highlighting for t2i templates and added a VitePress-style template. ([#7501](https://github.com/AstrBotDevs/AstrBot/pull/7501))
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved `EmptyModelOutputError` handling by no longer strictly enforcing whether model tool output names match the tool set, and instead guiding the model toward valid tool calls. ([#7375](https://github.com/AstrBotDevs/AstrBot/pull/7375))
|
||||
- Improved relative file path resolution within the local workspace root for `SendMessageToUserTool`. ([#7668](https://github.com/AstrBotDevs/AstrBot/pull/7668))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed Bocha search failures caused by Brotli-compressed responses reporting `Can not decode content-encoding: br`. ([#7655](https://github.com/AstrBotDevs/AstrBot/pull/7655))
|
||||
- Fixed `context_token` persistence for proactive Cron sends in the Weixin OC adapter. ([#7595](https://github.com/AstrBotDevs/AstrBot/pull/7595))
|
||||
- Increased the Anthropic Provider default `max_tokens` value to avoid heavy truncation during tool-call output for file operations. ([#7593](https://github.com/AstrBotDevs/AstrBot/pull/7593))
|
||||
- Displayed Cron `last_run_at` in the local timezone in Dashboard. ([#7625](https://github.com/AstrBotDevs/AstrBot/pull/7625))
|
||||
- Returned explicit errors to the model when Cron tool scheduling fails instead of continuing silently. ([#7513](https://github.com/AstrBotDevs/AstrBot/pull/7513))
|
||||
- Prevented Telegram media group exceptions from being silently swallowed. ([#7537](https://github.com/AstrBotDevs/AstrBot/pull/7537))
|
||||
- Fixed an `IndexError` when session `rate_limit_count` is 0. ([#7635](https://github.com/AstrBotDevs/AstrBot/pull/7635))
|
||||
- Improved knowledge-base upload error handling and error messages for better diagnostics. ([#7534](https://github.com/AstrBotDevs/AstrBot/pull/7534), [#7536](https://github.com/AstrBotDevs/AstrBot/pull/7536))
|
||||
- Fixed Dashboard chat attachment 401 errors. ([#7569](https://github.com/AstrBotDevs/AstrBot/pull/7569))
|
||||
- Fixed numeric inputs resetting to 0 on blur when the value was not edited. ([#7560](https://github.com/AstrBotDevs/AstrBot/pull/7560))
|
||||
- Fixed dark-mode propagation for code blocks inside Dashboard list items and removed the injected default value from `ThemeAwareMarkdownCodeBlock`. ([#7667](https://github.com/AstrBotDevs/AstrBot/pull/7667), [commit](https://github.com/AstrBotDevs/AstrBot/commit/fd2ca702d))
|
||||
- Added SOCKS proxy support for updater requests and related tests. ([#7615](https://github.com/AstrBotDevs/AstrBot/pull/7615))
|
||||
- Reverted the SCSS import warning fix to avoid related compatibility issues. ([#7616](https://github.com/AstrBotDevs/AstrBot/pull/7616))
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"subset-icons": "node scripts/subset-mdi-font.mjs",
|
||||
"build:t2i-shiki-runtime": "node scripts/build-t2i-shiki-runtime.mjs",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/",
|
||||
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
|
||||
|
||||
4
dashboard/pnpm-lock.yaml
generated
4
dashboard/pnpm-lock.yaml
generated
@@ -5,8 +5,8 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
immutable: 4.3.8
|
||||
lodash-es: 4.17.23
|
||||
immutable: "4.3.8"
|
||||
lodash-es: "4.17.23"
|
||||
|
||||
importers:
|
||||
|
||||
|
||||
232
dashboard/scripts/build-t2i-shiki-runtime.mjs
Normal file
232
dashboard/scripts/build-t2i-shiki-runtime.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { build } from "vite";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dashboardRoot = path.resolve(__dirname, "..");
|
||||
const runtimeOutputFile = path.resolve(
|
||||
dashboardRoot,
|
||||
"..",
|
||||
"astrbot",
|
||||
"core",
|
||||
"utils",
|
||||
"t2i",
|
||||
"template",
|
||||
"shiki_runtime.iife.js",
|
||||
);
|
||||
const shikiRequire = createRequire(require.resolve("shiki/package.json"));
|
||||
|
||||
const languageSpecs = [
|
||||
["bash", "bash"],
|
||||
["css", "css"],
|
||||
["html", "html"],
|
||||
["javascript", "javascript"],
|
||||
["json", "json"],
|
||||
["jsx", "jsx"],
|
||||
["markdown", "markdown"],
|
||||
["powershell", "powershell"],
|
||||
["python", "python"],
|
||||
["sql", "sql"],
|
||||
["tsx", "tsx"],
|
||||
["typescript", "typescript"],
|
||||
["xml", "xml"],
|
||||
["yaml", "yaml"],
|
||||
];
|
||||
|
||||
const themeSpecs = [
|
||||
["github-light", "github-light"],
|
||||
["github-dark", "github-dark"],
|
||||
];
|
||||
|
||||
// Shiki exposes plain text as a built-in special language, so we keep it
|
||||
// in the supported language list without importing a package for it.
|
||||
const builtInLanguageSpecs = ["text"];
|
||||
|
||||
const languageAliases = {
|
||||
bat: "powershell",
|
||||
cjs: "javascript",
|
||||
console: "bash",
|
||||
cts: "typescript",
|
||||
dockerfile: "bash",
|
||||
env: "bash",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
mts: "typescript",
|
||||
plain: "text",
|
||||
plaintext: "text",
|
||||
ps1: "powershell",
|
||||
pwsh: "powershell",
|
||||
py: "python",
|
||||
shell: "bash",
|
||||
shellscript: "bash",
|
||||
sh: "bash",
|
||||
svg: "xml",
|
||||
text: "text",
|
||||
ts: "typescript",
|
||||
txt: "text",
|
||||
vue: "html",
|
||||
xhtml: "html",
|
||||
xml: "xml",
|
||||
yml: "yaml",
|
||||
zsh: "bash",
|
||||
};
|
||||
|
||||
function resolveShikiModule(specifier) {
|
||||
return pathToFileURL(shikiRequire.resolve(specifier)).href;
|
||||
}
|
||||
|
||||
function buildVirtualSource() {
|
||||
const shikiImport = JSON.stringify(
|
||||
pathToFileURL(require.resolve("shiki")).href,
|
||||
);
|
||||
const languageImports = languageSpecs
|
||||
.map(
|
||||
([, packageName], index) =>
|
||||
`import lang${index} from ${JSON.stringify(resolveShikiModule(`@shikijs/langs/${packageName}`))};`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const themeImports = themeSpecs
|
||||
.map(
|
||||
([, packageName], index) =>
|
||||
`import theme${index} from ${JSON.stringify(resolveShikiModule(`@shikijs/themes/${packageName}`))};`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const supportedLanguages = [
|
||||
...builtInLanguageSpecs,
|
||||
...languageSpecs.map(([runtimeName]) => runtimeName),
|
||||
];
|
||||
|
||||
return `import { createHighlighterCoreSync, createJavaScriptRegexEngine } from ${shikiImport};
|
||||
${languageImports}
|
||||
${themeImports}
|
||||
|
||||
const highlighter = createHighlighterCoreSync({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: [${languageSpecs.map((_, index) => `...lang${index}`).join(", ")}],
|
||||
themes: [${themeSpecs.map((_, index) => `theme${index}`).join(", ")}],
|
||||
});
|
||||
|
||||
const supportedLanguages = new Set(${JSON.stringify(supportedLanguages)});
|
||||
const languageAliases = ${JSON.stringify(languageAliases)};
|
||||
const supportedThemes = new Set(${JSON.stringify(themeSpecs.map(([theme]) => theme))});
|
||||
|
||||
function normalizeLanguage(language) {
|
||||
const normalized = String(language || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
if (normalized in languageAliases) {
|
||||
return languageAliases[normalized];
|
||||
}
|
||||
|
||||
return supportedLanguages.has(normalized) ? normalized : "text";
|
||||
}
|
||||
|
||||
function normalizeTheme(theme) {
|
||||
const normalized = String(theme || "").trim();
|
||||
return supportedThemes.has(normalized) ? normalized : "github-light";
|
||||
}
|
||||
|
||||
function extractLanguage(codeElement) {
|
||||
const className = codeElement.className || "";
|
||||
const match = className.match(/language-([\\w+#.-]+)/i);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function renderCodeToHtml(code, language, theme) {
|
||||
const normalizedTheme = normalizeTheme(theme);
|
||||
|
||||
try {
|
||||
return highlighter.codeToHtml(String(code || ""), {
|
||||
lang: normalizeLanguage(language),
|
||||
theme: normalizedTheme,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to render T2I code block with Shiki.", error);
|
||||
return highlighter.codeToHtml(String(code || ""), {
|
||||
lang: "text",
|
||||
theme: normalizedTheme,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function highlightAllCodeBlocks(root, theme) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.querySelectorAll("pre > code").forEach((codeElement) => {
|
||||
const preElement = codeElement.parentElement;
|
||||
if (!preElement || preElement.classList.contains("shiki")) {
|
||||
return;
|
||||
}
|
||||
|
||||
preElement.outerHTML = renderCodeToHtml(
|
||||
codeElement.textContent || "",
|
||||
extractLanguage(codeElement),
|
||||
theme,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.AstrBotT2IShiki = Object.freeze({
|
||||
highlightAllCodeBlocks,
|
||||
normalizeLanguage,
|
||||
renderCodeToHtml,
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "astrbot-t2i-shiki-runtime-"));
|
||||
const entryPath = path.join(tempDir, "entry.mjs");
|
||||
|
||||
writeFileSync(entryPath, buildVirtualSource(), "utf-8");
|
||||
|
||||
try {
|
||||
await build({
|
||||
configFile: false,
|
||||
logLevel: "info",
|
||||
publicDir: false,
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
cssCodeSplit: false,
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: entryPath,
|
||||
fileName: () => path.basename(runtimeOutputFile),
|
||||
formats: ["iife"],
|
||||
name: "AstrBotT2IShikiRuntime",
|
||||
},
|
||||
minify: "esbuild",
|
||||
outDir: path.dirname(runtimeOutputFile),
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
target: "es2018",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Built ${runtimeOutputFile}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -860,17 +860,15 @@ export default {
|
||||
// 过滤出属于该平台的路由,并保持顺序
|
||||
const routes = [];
|
||||
for (const [umop, confId] of Object.entries(routingTable)) {
|
||||
if (this.isUmopMatchPlatform(umop, platformId)) {
|
||||
const parts = umop.split(':');
|
||||
if (parts.length === 3) {
|
||||
routes.push({
|
||||
umop: umop,
|
||||
originalUmop: umop, // 保存原始 UMOP 用于更新时查找
|
||||
messageType: parts[1] === '' || parts[1] === '*' ? '*' : parts[1],
|
||||
sessionId: parts[2] === '' || parts[2] === '*' ? '*' : parts[2],
|
||||
configId: confId
|
||||
});
|
||||
}
|
||||
const parsedUmop = this.parseUmop(umop);
|
||||
if (this.isParsedUmopMatchPlatform(parsedUmop, platformId)) {
|
||||
routes.push({
|
||||
umop: umop,
|
||||
originalUmop: umop, // 保存原始 UMOP 用于更新时查找
|
||||
messageType: parsedUmop.messageType || '*',
|
||||
sessionId: parsedUmop.sessionId || '*',
|
||||
configId: confId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,11 +990,29 @@ export default {
|
||||
},
|
||||
|
||||
isUmopMatchPlatform(umop, platformId) {
|
||||
if (!umop) return false;
|
||||
const parts = umop.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
const platform = parts[0];
|
||||
return platform === platformId || platform === '' || platform === '*';
|
||||
const parsedUmop = this.parseUmop(umop);
|
||||
return this.isParsedUmopMatchPlatform(parsedUmop, platformId);
|
||||
},
|
||||
|
||||
isParsedUmopMatchPlatform(parsedUmop, platformId) {
|
||||
if (!parsedUmop) return false;
|
||||
return parsedUmop.platform === platformId || parsedUmop.platform === '' || parsedUmop.platform === '*';
|
||||
},
|
||||
|
||||
parseUmop(umop) {
|
||||
if (!umop) return null;
|
||||
|
||||
const firstSeparatorIndex = umop.indexOf(':');
|
||||
if (firstSeparatorIndex === -1) return null;
|
||||
|
||||
const secondSeparatorIndex = umop.indexOf(':', firstSeparatorIndex + 1);
|
||||
if (secondSeparatorIndex === -1) return null;
|
||||
|
||||
return {
|
||||
platform: umop.slice(0, firstSeparatorIndex),
|
||||
messageType: umop.slice(firstSeparatorIndex + 1, secondSeparatorIndex),
|
||||
sessionId: umop.slice(secondSeparatorIndex + 1)
|
||||
};
|
||||
},
|
||||
|
||||
// 获取消息类型标签
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<v-text-field
|
||||
:model-value="numericTemp ?? modelValue"
|
||||
@update:model-value="val => (numericTemp = val)"
|
||||
@blur="() => { emitUpdate(toNumber(numericTemp)); numericTemp = null }"
|
||||
@blur="() => { if (numericTemp != null) { emitUpdate(toNumber(numericTemp)) } numericTemp = null }"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, inject, type Ref } from "vue";
|
||||
import { MarkdownCodeBlockNode } from "markstream-vue";
|
||||
import { useAttrs } from "vue";
|
||||
|
||||
@@ -21,20 +21,21 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: Record<string, unknown>;
|
||||
isDark?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDark: false,
|
||||
},
|
||||
const props = defineProps<{
|
||||
node: Record<string, unknown>;
|
||||
isDark?: boolean;
|
||||
}>();
|
||||
|
||||
const injectedIsDark = inject<Ref<boolean> | boolean>("isDark");
|
||||
const effectiveIsDark = computed(
|
||||
() => props.isDark ?? (injectedIsDark instanceof Object && "value" in injectedIsDark ? injectedIsDark.value : injectedIsDark) ?? false,
|
||||
);
|
||||
|
||||
const attrs = useAttrs();
|
||||
const forwardedBindings = computed(() => ({
|
||||
...attrs,
|
||||
...props,
|
||||
isDark: effectiveIsDark.value,
|
||||
}));
|
||||
const themeRenderKey = computed(() => (props.isDark ? "dark" : "light"));
|
||||
const themeRenderKey = computed(() => (effectiveIsDark.value ? "dark" : "light"));
|
||||
</script>
|
||||
|
||||
@@ -86,6 +86,7 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
const messagesBySession = reactive<Record<string, ChatRecord[]>>({});
|
||||
const loadedSessions = reactive<Record<string, boolean>>({});
|
||||
const activeConnections = reactive<Record<string, ActiveConnection>>({});
|
||||
const attachmentBlobCache = new Map<string, Promise<string>>();
|
||||
const sessionProjects = reactive<Record<string, ChatSessionProject | null>>(
|
||||
{},
|
||||
);
|
||||
@@ -98,6 +99,10 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupConnections();
|
||||
for (const promise of attachmentBlobCache.values()) {
|
||||
promise.then((url) => URL.revokeObjectURL(url)).catch(() => {});
|
||||
}
|
||||
attachmentBlobCache.clear();
|
||||
});
|
||||
|
||||
function isSessionRunning(sessionId: string) {
|
||||
@@ -129,6 +134,47 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
return !isUserMessage(msg) && msgIndex === activeMessages.value.length - 1;
|
||||
}
|
||||
|
||||
async function resolvePartMedia(part: MessagePart): Promise<void> {
|
||||
if (part.embedded_url) return;
|
||||
let url: string;
|
||||
let cacheKey: string;
|
||||
if (part.attachment_id) {
|
||||
cacheKey = `att:${part.attachment_id}`;
|
||||
url = `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`;
|
||||
} else if (part.filename) {
|
||||
cacheKey = `file:${part.filename}`;
|
||||
url = `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let promise = attachmentBlobCache.get(cacheKey);
|
||||
if (!promise) {
|
||||
promise = axios
|
||||
.get(url, { responseType: "blob" })
|
||||
.then((resp) => URL.createObjectURL(resp.data));
|
||||
attachmentBlobCache.set(cacheKey, promise);
|
||||
}
|
||||
try {
|
||||
part.embedded_url = await promise;
|
||||
} catch (e) {
|
||||
attachmentBlobCache.delete(cacheKey);
|
||||
console.error("Failed to resolve media:", cacheKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRecordMedia(records: ChatRecord[]) {
|
||||
const mediaTypes = ["image", "record", "video"];
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const record of records) {
|
||||
for (const part of record.content?.message || []) {
|
||||
if (mediaTypes.includes(part.type) && !part.embedded_url && (part.attachment_id || part.filename)) {
|
||||
tasks.push(resolvePartMedia(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
async function loadSessionMessages(sessionId: string) {
|
||||
if (!sessionId) return;
|
||||
loadingMessages.value = true;
|
||||
@@ -138,7 +184,9 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
});
|
||||
const payload = response.data?.data || {};
|
||||
const history = payload.history || [];
|
||||
messagesBySession[sessionId] = history.map(normalizeHistoryRecord);
|
||||
const records = history.map(normalizeHistoryRecord);
|
||||
await resolveRecordMedia(records);
|
||||
messagesBySession[sessionId] = records;
|
||||
sessionProjects[sessionId] = normalizeSessionProject(payload.project);
|
||||
loadedSessions[sessionId] = true;
|
||||
} catch (error) {
|
||||
@@ -438,7 +486,14 @@ export function useMessages(options: UseMessagesOptions) {
|
||||
.replace("[FILE]", "")
|
||||
.replace("[VIDEO]", "")
|
||||
.split("|", 1)[0];
|
||||
messageContent(botRecord).message.push({ type: msgType, filename });
|
||||
const mediaPart: MessagePart = { type: msgType, filename };
|
||||
if (msgType !== "file") {
|
||||
resolvePartMedia(mediaPart).then(() => {
|
||||
messageContent(botRecord).message.push(mediaPart);
|
||||
});
|
||||
} else {
|
||||
messageContent(botRecord).message.push(mediaPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export function getProviderIcon(type) {
|
||||
'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'kimi': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
|
||||
'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg',
|
||||
'longcat': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg',
|
||||
'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
|
||||
'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
|
||||
"coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
|
||||
@@ -33,6 +33,7 @@ export function getProviderIcon(type) {
|
||||
'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg',
|
||||
'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg',
|
||||
'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
|
||||
'minimax-token-plan': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
|
||||
'mimo': 'https://platform.xiaomimimo.com/favicon.874c9507.png',
|
||||
'302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg',
|
||||
'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg',
|
||||
|
||||
@@ -12,11 +12,13 @@ If `uv` is not installed, install it first by following the official guide:
|
||||
## Important Notes
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, run `uv tool upgrade astrbot` from the command line.
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, run `uv tool upgrade astrbot --python 3.12` from the command line.
|
||||
|
||||
AstrBot requires Python 3.12 or later. Use `--python 3.12` to ensure that `uv` creates the tool environment with Python 3.12; if Python downloads are enabled, `uv` will download Python 3.12 automatically when it is missing.
|
||||
|
||||
## Install and Start
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot
|
||||
```
|
||||
|
||||
@@ -289,6 +289,94 @@ async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # Note
|
||||
|
||||
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
|
||||
|
||||
#### On Agent Begin
|
||||
|
||||
> Requires AstrBot version > v4.23.1
|
||||
|
||||
When the Agent starts running, the `on_agent_begin` hook is triggered.
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_begin()
|
||||
async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]): # Note there are three parameters
|
||||
print("Agent started")
|
||||
```
|
||||
|
||||
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
|
||||
|
||||
#### Before LLM Tool Call
|
||||
|
||||
> Requires AstrBot version > v4.23.1
|
||||
|
||||
When the Agent is about to call an LLM tool, the `on_using_llm_tool` hook is triggered.
|
||||
|
||||
You can obtain the `FunctionTool` object and tool call arguments.
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_using_llm_tool()
|
||||
async def on_using_llm_tool(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
):
|
||||
print(tool.name, tool_args)
|
||||
```
|
||||
|
||||
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
|
||||
|
||||
#### After LLM Tool Call
|
||||
|
||||
> Requires AstrBot version > v4.23.1
|
||||
|
||||
After the LLM tool call completes, the `on_llm_tool_respond` hook is triggered.
|
||||
|
||||
You can obtain the `FunctionTool` object, tool call arguments, and tool call result.
|
||||
|
||||
```python
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_llm_tool_respond()
|
||||
async def on_llm_tool_respond(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
tool_result: CallToolResult | None,
|
||||
):
|
||||
print(tool.name, tool_args, tool_result)
|
||||
```
|
||||
|
||||
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
|
||||
|
||||
#### On Agent Done
|
||||
|
||||
> Requires AstrBot version > v4.23.1
|
||||
|
||||
After the Agent finishes running, the `on_agent_done` hook is triggered. This hook is triggered after `on_llm_response`.
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.api.provider import LLMResponse
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_done()
|
||||
async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse): # Note there are four parameters
|
||||
print(resp)
|
||||
```
|
||||
|
||||
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
|
||||
|
||||
#### Before Sending Message
|
||||
|
||||
Before sending a message, the `on_decorating_result` hook is triggered.
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
## 注意事项
|
||||
|
||||
> [!WARNING]
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot`。
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot --python 3.12`。
|
||||
|
||||
AstrBot 需要 Python 3.12 或更高版本。使用 `--python 3.12` 可以确保 `uv` 使用 Python 3.12 创建 tool 环境;如果启用了 Python 自动下载,`uv` 会在缺少 Python 3.12 时自动下载。
|
||||
|
||||
## 安装并启动
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
uv tool install astrbot --python 3.12
|
||||
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
|
||||
astrbot run
|
||||
```
|
||||
|
||||
@@ -305,6 +305,94 @@ async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # 请
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
#### Agent 开始运行时
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 开始运行时,会触发 `on_agent_begin` 钩子。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_begin()
|
||||
async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]):
|
||||
print("Agent 开始运行")
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
#### LLM 工具调用前
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 准备调用 LLM 工具时,会触发 `on_using_llm_tool` 钩子。
|
||||
|
||||
可以获取到 `FunctionTool` 对象和工具调用参数。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_using_llm_tool()
|
||||
async def on_using_llm_tool(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
):
|
||||
print(tool.name, tool_args)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
#### LLM 工具调用后
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 LLM 工具调用完成后,会触发 `on_llm_tool_respond` 钩子。
|
||||
|
||||
可以获取到 `FunctionTool` 对象、工具调用参数和工具调用结果。
|
||||
|
||||
```python
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_llm_tool_respond()
|
||||
async def on_llm_tool_respond(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
tool_result: CallToolResult | None,
|
||||
):
|
||||
print(tool.name, tool_args, tool_result)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
#### Agent 运行完成时
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 运行完成后,会触发 `on_agent_done` 钩子。这个钩子会在 `on_llm_response` 之后触发。本质上和 `on_llm_response` 一样。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.api.provider import LLMResponse
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_done()
|
||||
async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse):
|
||||
print(resp)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
#### 发送消息前
|
||||
|
||||
在发送消息前,会触发 `on_decorating_result` 钩子。
|
||||
|
||||
@@ -507,6 +507,96 @@ async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # 请
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
##### Agent 开始运行时
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 开始运行时,会触发 `on_agent_begin` 钩子。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_begin()
|
||||
async def on_agent_begin(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext]): # 请注意有三个参数
|
||||
print("Agent 开始运行")
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
##### LLM 工具调用前
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 准备调用 LLM 工具时,会触发 `on_using_llm_tool` 钩子。
|
||||
|
||||
可以获取到 `FunctionTool` 对象和工具调用参数。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_using_llm_tool()
|
||||
async def on_using_llm_tool(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
):
|
||||
print(tool.name, tool_args)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
##### LLM 工具调用后
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 LLM 工具调用完成后,会触发 `on_llm_tool_respond` 钩子。
|
||||
|
||||
可以获取到 `FunctionTool` 对象、工具调用参数和工具调用结果。
|
||||
|
||||
```python
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
|
||||
@filter.on_llm_tool_respond()
|
||||
async def on_llm_tool_respond(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
tool: FunctionTool,
|
||||
tool_args: dict | None,
|
||||
tool_result: CallToolResult | None,
|
||||
):
|
||||
print(tool.name, tool_args, tool_result)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
##### Agent 运行完成时
|
||||
|
||||
> 适用于 AstrBot 版本 > v4.23.1
|
||||
|
||||
在 Agent 运行完成后,会触发 `on_agent_done` 钩子。这个钩子会在 `on_llm_response` 之后触发。
|
||||
|
||||
可以获取到 `LLMResponse` 对象,可以对其进行修改。
|
||||
|
||||
```python
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.api.provider import LLMResponse
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
@filter.on_agent_done()
|
||||
async def on_agent_done(self, event: AstrMessageEvent, run_context: ContextWrapper[AstrAgentContext], resp: LLMResponse): # 请注意有四个参数
|
||||
print(resp)
|
||||
```
|
||||
|
||||
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
|
||||
|
||||
##### 发送消息前
|
||||
|
||||
在发送消息前,会触发 `on_decorating_result` 钩子。
|
||||
|
||||
@@ -6,7 +6,7 @@ outline: deep
|
||||
|
||||
## 简介
|
||||
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||
|
||||
## 文档概览
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.23.0"
|
||||
version = "4.23.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"python-socks>=2.8.0",
|
||||
"pysocks>=1.7.1",
|
||||
"packaging>=24.2",
|
||||
"python-ripgrep==0.0.9",
|
||||
"python-ripgrep==0.0.8",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -53,4 +53,4 @@ shipyard-python-sdk>=0.2.4
|
||||
shipyard-neo-sdk>=0.2.0
|
||||
packaging>=24.2
|
||||
qrcode>=8.2
|
||||
python-ripgrep==0.0.9
|
||||
python-ripgrep==0.0.8
|
||||
116
scripts/smoke_startup_check.py
Normal file
116
scripts/smoke_startup_check.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Cross-platform startup smoke check for AstrBot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HEALTH_URL = "http://127.0.0.1:6185"
|
||||
STARTUP_TIMEOUT_SECONDS = 60
|
||||
REQUEST_TIMEOUT_SECONDS = 2
|
||||
|
||||
|
||||
def _tail(path: Path, lines: int = 80) -> str:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as exc:
|
||||
return f"Unable to read smoke log: {exc}"
|
||||
return "\n".join(content[-lines:])
|
||||
|
||||
|
||||
def _is_ready() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310
|
||||
HEALTH_URL,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
return response.status < 400
|
||||
except (OSError, urllib.error.URLError):
|
||||
return False
|
||||
|
||||
|
||||
def _stop_process(proc: subprocess.Popen[bytes]) -> None:
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=10)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = os.environ.copy()
|
||||
env.setdefault("PYTHONUTF8", "1")
|
||||
env.setdefault("TESTING", "true")
|
||||
|
||||
smoke_root = Path(tempfile.mkdtemp(prefix="astrbot-smoke-root-"))
|
||||
env["ASTRBOT_ROOT"] = str(smoke_root)
|
||||
log_path = smoke_root / "smoke.log"
|
||||
webui_dir = smoke_root / "webui"
|
||||
webui_dir.mkdir()
|
||||
(webui_dir / "index.html").write_text(
|
||||
"<!doctype html><title>AstrBot</title>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with log_path.open("wb") as log_file:
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "main.py"),
|
||||
"--webui-dir",
|
||||
str(webui_dir),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
print(f"Starting smoke test on {HEALTH_URL}")
|
||||
deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if _is_ready():
|
||||
print("Smoke test passed")
|
||||
return 0
|
||||
|
||||
return_code = proc.poll()
|
||||
if return_code is not None:
|
||||
print(
|
||||
f"AstrBot exited before becoming healthy. Exit code: {return_code}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(_tail(log_path), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
print(
|
||||
"Smoke test failed: health endpoint did not become ready in time.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(_tail(log_path), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
_stop_process(proc)
|
||||
try:
|
||||
log_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(smoke_root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -8,8 +8,10 @@ from quart import Quart
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.exceptions import KnowledgeBaseUploadError
|
||||
from astrbot.core.knowledge_base.kb_helper import KBHelper
|
||||
from astrbot.core.knowledge_base.models import KBDocument
|
||||
from astrbot.dashboard.routes.knowledge_base import KnowledgeBaseRoute
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
@@ -87,6 +89,9 @@ async def test_import_documents(
|
||||
):
|
||||
"""Tests the import documents functionality."""
|
||||
test_client = app.test_client()
|
||||
kb_helper = await core_lifecycle_td.kb_manager.get_kb("test_kb_id")
|
||||
kb_helper.upload_document.reset_mock()
|
||||
kb_helper.upload_document.side_effect = None
|
||||
|
||||
# Test data
|
||||
import_data = {
|
||||
@@ -129,7 +134,6 @@ async def test_import_documents(
|
||||
assert result["failed_count"] == 0
|
||||
|
||||
# Verify kb_helper.upload_document was called correctly
|
||||
kb_helper = await core_lifecycle_td.kb_manager.get_kb("test_kb_id")
|
||||
assert kb_helper.upload_document.call_count == 2
|
||||
|
||||
# Check first call arguments
|
||||
@@ -146,6 +150,48 @@ async def test_import_documents(
|
||||
assert kwargs2["pre_chunked_text"] == ["chunk3", "chunk4", "chunk5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_documents_returns_friendly_failure_message(
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
kb_helper = await core_lifecycle_td.kb_manager.get_kb("test_kb_id")
|
||||
kb_helper.upload_document.reset_mock()
|
||||
kb_helper.upload_document.side_effect = KnowledgeBaseUploadError(
|
||||
stage="embedding",
|
||||
user_message=(
|
||||
"向量化失败:嵌入模型返回的向量数量与文本分块数量不一致(期望 2,实际 1)。"
|
||||
),
|
||||
details={"expected_contents": 2, "actual_vectors": 1},
|
||||
)
|
||||
|
||||
route = KnowledgeBaseRoute.__new__(KnowledgeBaseRoute)
|
||||
route.upload_progress = {}
|
||||
route.upload_tasks = {}
|
||||
|
||||
await KnowledgeBaseRoute._background_import_task(
|
||||
route,
|
||||
task_id="task-1",
|
||||
kb_helper=kb_helper,
|
||||
documents=[{"file_name": "broken.txt", "chunks": ["chunk1", "chunk2"]}],
|
||||
batch_size=32,
|
||||
tasks_limit=3,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
assert route.upload_tasks["task-1"]["status"] == "completed"
|
||||
result = route.upload_tasks["task-1"]["result"]
|
||||
assert result["success_count"] == 0
|
||||
assert result["failed_count"] == 1
|
||||
assert result["failed"][0]["file_name"] == "broken.txt"
|
||||
assert result["failed"][0]["error"].startswith("broken.txt:")
|
||||
assert "向量化失败" in result["failed"][0]["error"]
|
||||
assert "期望 2,实际 1" in result["failed"][0]["error"]
|
||||
assert "not same nb of vectors as ids" not in result["failed"][0]["error"]
|
||||
assert kb_helper.upload_document.await_count == 1
|
||||
|
||||
kb_helper.upload_document.side_effect = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_documents_invalid_input(app: Quart, authenticated_header: dict):
|
||||
"""Tests import documents with invalid input."""
|
||||
|
||||
36
tests/test_kook/data/kook_api_response_user_me.json
Normal file
36
tests/test_kook/data/kook_api_response_user_me.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"code": 0,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"id": "573092175",
|
||||
"username": "bot_username",
|
||||
"identify_num": "9561",
|
||||
"online": false,
|
||||
"os": "Websocket",
|
||||
"status": 0,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"banner": "",
|
||||
"nickname": "bot_nickname",
|
||||
"roles": [],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": true,
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"bot_status": 0,
|
||||
"tag_info": {
|
||||
"color": "#0096FF",
|
||||
"bg_color": "#0096FF33",
|
||||
"text": "机器人"
|
||||
},
|
||||
"mobile_verified": true,
|
||||
"is_sys": false,
|
||||
"client_id": "g3nsxNQhNMZFKatU",
|
||||
"verified": false,
|
||||
"mobile_prefix": "86",
|
||||
"mobile": "****",
|
||||
"invited_count": 0,
|
||||
"intent": 255
|
||||
}
|
||||
}
|
||||
36
tests/test_kook/data/kook_api_response_user_view.json
Normal file
36
tests/test_kook/data/kook_api_response_user_view.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"code": 0,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"id": "573092175",
|
||||
"username": "bot_username",
|
||||
"identify_num": "9561",
|
||||
"online": false,
|
||||
"os": "Websocket",
|
||||
"status": 0,
|
||||
"avatar": "https://img.kookapp.cn/assets/bot.png?x-oss-process=style/icon",
|
||||
"vip_avatar": "https://img.kookapp.cn/assets/bot.png?x-oss-process=style/icon",
|
||||
"banner": "",
|
||||
"nickname": "bot_nickname",
|
||||
"roles": [
|
||||
13726212
|
||||
],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": true,
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"bot_status": 0,
|
||||
"tag_info": {
|
||||
"color": "#0096FF",
|
||||
"bg_color": "#0096FF33",
|
||||
"text": "机器人"
|
||||
},
|
||||
"mobile_verified": true,
|
||||
"is_sys": false,
|
||||
"client_id": "g3nsxNQhNMZFKatU",
|
||||
"verified": false,
|
||||
"joined_at": 1772260532000,
|
||||
"active_time": 1776418003694
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"banner": "",
|
||||
"nickname": "some_username",
|
||||
"roles": [
|
||||
63724577
|
||||
63423577
|
||||
],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "GROUP",
|
||||
"type": 9,
|
||||
"target_id": "2732467349811313213",
|
||||
"author_id": "7324688132731983",
|
||||
"content": "(rol)25555643(rol) /help (met)3351526782(met) (met)all(met) ",
|
||||
"msg_id": "9b047d81-40fe-41af-ad39-916ae77e6b20",
|
||||
"msg_timestamp": 1776405840600,
|
||||
"nonce": "r2oQkO7kRpNSgmsv7TNl2zOA",
|
||||
"from_type": 1,
|
||||
"extra": {
|
||||
"type": 9,
|
||||
"code": "",
|
||||
"author": {
|
||||
"id": "3351526782",
|
||||
"username": "some_username",
|
||||
"identify_num": "4198",
|
||||
"nickname": "some_username",
|
||||
"bot": false,
|
||||
"online": true,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"status": 1,
|
||||
"roles": [
|
||||
63423577
|
||||
],
|
||||
"os": "Websocket",
|
||||
"banner": "",
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"nameplate": [],
|
||||
"wealth_level": 0,
|
||||
"is_sys": false
|
||||
},
|
||||
"kmarkdown": {
|
||||
"raw_content": "@some_role /help @some_username @全体成员",
|
||||
"mention_part": [
|
||||
{
|
||||
"id": "3351526782",
|
||||
"username": "some_username",
|
||||
"full_name": "some_username#4198",
|
||||
"avatar": "https://example.com",
|
||||
"wealth_level": 0
|
||||
}
|
||||
],
|
||||
"mention_role_part": [
|
||||
{
|
||||
"role_id": 25555643,
|
||||
"name": "some_role",
|
||||
"color": 0,
|
||||
"color_type": 1,
|
||||
"color_map": []
|
||||
}
|
||||
],
|
||||
"channel_part": [],
|
||||
"spl": []
|
||||
},
|
||||
"last_msg_content": "some_username:@some_role /help @some_username @全体成员",
|
||||
"mention": [
|
||||
"3351526782"
|
||||
],
|
||||
"mention_all": true,
|
||||
"mention_here": false,
|
||||
"guild_id": "1239678456780469",
|
||||
"guild_type": 0,
|
||||
"channel_name": "聊天大厅",
|
||||
"visible_only": "",
|
||||
"mention_no_at": [],
|
||||
"mention_roles": [
|
||||
25555643
|
||||
],
|
||||
"nav_channels": [],
|
||||
"emoji": [],
|
||||
"preview_content": "",
|
||||
"channel_type": 1,
|
||||
"send_msg_device": 0
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 1
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "GROUP",
|
||||
"type": 255,
|
||||
"target_id": "6059671921720469",
|
||||
"author_id": "1",
|
||||
"content": "[系统消息]",
|
||||
"msg_id": "35de353d-aa69-40a8-b329-e4498ed8d30d",
|
||||
"msg_timestamp": 1776498088031,
|
||||
"nonce": "",
|
||||
"from_type": 1,
|
||||
"extra": {
|
||||
"type": "updated_role",
|
||||
"body": {
|
||||
"role_id": 16752184,
|
||||
"name": "some_username",
|
||||
"color": 0,
|
||||
"position": 5,
|
||||
"hoist": 0,
|
||||
"mentionable": 0,
|
||||
"permissions": 5571747823,
|
||||
"desc": "",
|
||||
"color_type": 1,
|
||||
"color_map": {},
|
||||
"type": 1,
|
||||
"op_permissions": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"sn": 4,
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,111 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
import aiohttp
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType
|
||||
from astrbot.core.message.components import (
|
||||
File,
|
||||
Record,
|
||||
)
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
CURRENT_DIR = Path(__file__).parent
|
||||
TEST_DATA_DIR = CURRENT_DIR / "data"
|
||||
|
||||
|
||||
class KookEventDataPath:
|
||||
GROUP_MESSAGE_WITH_MENTION = (
|
||||
TEST_DATA_DIR / "kook_ws_event_group_message_with_mention.json"
|
||||
)
|
||||
GROUP_MESSAGE = TEST_DATA_DIR / "kook_ws_event_group_message.json"
|
||||
HELLO = TEST_DATA_DIR / "kook_ws_event_hello.json"
|
||||
MESSAGE_WITH_CARD_1 = TEST_DATA_DIR / "kook_ws_event_message_with_card_1.json"
|
||||
MESSAGE_WITH_CARD_2 = TEST_DATA_DIR / "kook_ws_event_message_with_card_2.json"
|
||||
PING = TEST_DATA_DIR / "kook_ws_event_ping.json"
|
||||
PONG = TEST_DATA_DIR / "kook_ws_event_pong.json"
|
||||
PRIVATE_MESSAGE = TEST_DATA_DIR / "kook_ws_event_private_message.json"
|
||||
PRIVATE_SYSTEM_MESSAGE = TEST_DATA_DIR / "kook_ws_event_private_system_message.json"
|
||||
RECONNECT_ERR = TEST_DATA_DIR / "kook_ws_event_reconnect_err.json"
|
||||
RESUME_ACK = TEST_DATA_DIR / "kook_ws_event_resume_ack.json"
|
||||
RESUME = TEST_DATA_DIR / "kook_ws_event_resume.json"
|
||||
GROUP_SYSTEM_MESSAGE_UPDATE_ROLE = TEST_DATA_DIR / "kook_ws_event_group_system_message_update_role.json"
|
||||
|
||||
|
||||
class KookApiDataPath:
|
||||
USER_ME = TEST_DATA_DIR / "kook_api_response_user_me.json"
|
||||
USER_VIEW = TEST_DATA_DIR / "kook_api_response_user_view.json"
|
||||
|
||||
|
||||
def mock_kook_client(upload_asset_return: str, send_text_return: str):
|
||||
client = MagicMock()
|
||||
|
||||
client.upload_asset = AsyncMock(return_value=upload_asset_return)
|
||||
client.send_text = AsyncMock(return_value=send_text_return)
|
||||
return client
|
||||
|
||||
|
||||
def mock_http_client(
|
||||
http_method: str = "get",
|
||||
return_value: str | dict | list | None = None,
|
||||
status: int = 200,
|
||||
):
|
||||
"""Mock aiohttp ClientSession"""
|
||||
|
||||
if isinstance(return_value, (dict, list)):
|
||||
response_text = json.dumps(return_value)
|
||||
else:
|
||||
response_text = return_value or "{}"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = status
|
||||
mock_response.text = AsyncMock(return_value=response_text)
|
||||
mock_response.json = AsyncMock(
|
||||
return_value=json.loads(response_text) if response_text else {}
|
||||
)
|
||||
mock_response.read = AsyncMock(return_value=response_text.encode())
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session = MagicMock()
|
||||
|
||||
async def mock_method(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
setattr(mock_session, http_method.lower(), mock_method)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_session.close = AsyncMock()
|
||||
|
||||
return mock_session
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def mock_kook_roles_record(bot_id: str, http_client: aiohttp.ClientSession):
|
||||
instance = AsyncMock()
|
||||
instance.has_role_in_channel = AsyncMock(return_value=True)
|
||||
return instance
|
||||
|
||||
156
tests/test_kook/test_kook_client.py
Normal file
156
tests/test_kook/test_kook_client.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
BaseMessageComponent,
|
||||
Plain,
|
||||
Record,
|
||||
)
|
||||
from astrbot.core.platform.sources.kook.kook_client import KookClient
|
||||
from astrbot.core.platform.sources.kook.kook_config import KookConfig
|
||||
from astrbot.core.platform.sources.kook.kook_types import (
|
||||
KookMessageEventData,
|
||||
KookWebsocketEvent,
|
||||
)
|
||||
from tests.test_kook.shared import (
|
||||
KookEventDataPath,
|
||||
mock_http_client,
|
||||
mock_kook_roles_record,
|
||||
)
|
||||
|
||||
TEST_BOT_ID = 1234567891
|
||||
TEST_BOT_USERNAME = "test_username"
|
||||
TEST_BOT_NICKNAME = "test_nickname"
|
||||
|
||||
|
||||
def mock_kook_client(config: KookConfig, event_callback):
|
||||
class MockKookClient:
|
||||
def __init__(self, config, callback):
|
||||
self.bot_id = TEST_BOT_ID
|
||||
self.bot_nickname = TEST_BOT_NICKNAME
|
||||
self.bot_username = TEST_BOT_USERNAME
|
||||
self.http_client = mock_http_client()
|
||||
self.connect = AsyncMock()
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
return MockKookClient(config, event_callback)
|
||||
|
||||
|
||||
def get_json_field(content: dict, json_field_path: list[str | int]) -> Any:
|
||||
expend_value = content
|
||||
for key in json_field_path:
|
||||
expend_value = expend_value[key]
|
||||
return expend_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class JsonFieldPaths:
|
||||
message_str: list[int | str] = field(default_factory=list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"expected_json_data_path, expected_message_str, expected_message_components",
|
||||
[
|
||||
(
|
||||
KookEventDataPath.GROUP_MESSAGE_WITH_MENTION,
|
||||
["d", "extra", "kmarkdown", "raw_content"],
|
||||
[
|
||||
# 这里默认机器人一定属于某个角色id
|
||||
At(qq=TEST_BOT_ID, name="some_role"),
|
||||
Plain(text="/help"),
|
||||
At(qq=3351526782, name="some_username"),
|
||||
AtAll(qq="all", name=""),
|
||||
],
|
||||
),
|
||||
(
|
||||
KookEventDataPath.GROUP_MESSAGE,
|
||||
["d", "extra", "kmarkdown", "raw_content"],
|
||||
[Plain(text="done!")],
|
||||
),
|
||||
(
|
||||
KookEventDataPath.MESSAGE_WITH_CARD_1,
|
||||
"[audio]",
|
||||
[
|
||||
Plain(text="[audio]"),
|
||||
Record(
|
||||
file="https://img.kookapp.cn/attachments/2026-03/03/69a6841c3125d.wav",
|
||||
url="",
|
||||
text=None,
|
||||
path=None,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
KookEventDataPath.MESSAGE_WITH_CARD_2,
|
||||
["d", "extra", "kmarkdown", "raw_content"],
|
||||
[
|
||||
Plain(text="(met)"),
|
||||
Plain(text="all(met) #hello \\*\\*world\\*\\* [audio]\n😆"),
|
||||
Record(
|
||||
file="https://img.kookapp.cn/attachments/2026-03/03/69a6841c3125d.wav",
|
||||
url="",
|
||||
text=None,
|
||||
path=None,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
KookEventDataPath.PRIVATE_MESSAGE,
|
||||
["d", "extra", "kmarkdown", "raw_content"],
|
||||
[Plain(text="/help")],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_kook_event_warp_message(
|
||||
expected_json_data_path: Path,
|
||||
expected_message_str: list[int | str] | str,
|
||||
expected_message_components: list[BaseMessageComponent],
|
||||
):
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.platform.sources.kook.kook_adapter.KookClient", mock_kook_client
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.platform.sources.kook.kook_adapter.KookRolesRecord",
|
||||
mock_kook_roles_record,
|
||||
)
|
||||
|
||||
from astrbot.core.platform.sources.kook.kook_adapter import KookPlatformAdapter
|
||||
|
||||
adapter = KookPlatformAdapter({}, {}, asyncio.Queue())
|
||||
|
||||
raw_event_str = expected_json_data_path.read_text(encoding="utf-8")
|
||||
raw_event = json.loads(raw_event_str)
|
||||
event = KookWebsocketEvent.from_json(
|
||||
raw_event_str,
|
||||
)
|
||||
assert isinstance(event.data, KookMessageEventData)
|
||||
|
||||
astrbotMessage = await adapter.convert_message(event.data)
|
||||
assert astrbotMessage.self_id == TEST_BOT_ID
|
||||
assert astrbotMessage.sender.user_id == raw_event["d"]["author_id"]
|
||||
assert (
|
||||
astrbotMessage.sender.nickname == raw_event["d"]["extra"]["author"]["username"]
|
||||
)
|
||||
assert astrbotMessage.raw_message == raw_event["d"]
|
||||
assert astrbotMessage.message_id == raw_event["d"]["msg_id"]
|
||||
assert astrbotMessage.message == expected_message_components
|
||||
if isinstance(expected_message_str, str):
|
||||
assert astrbotMessage.message_str == expected_message_str
|
||||
else:
|
||||
assert get_json_field(raw_event, expected_message_str)
|
||||
@@ -1,10 +1,8 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata, Unknown
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.platform import PlatformMetadata, Unknown
|
||||
from astrbot.core.message.components import (
|
||||
File,
|
||||
Image,
|
||||
Plain,
|
||||
Video,
|
||||
@@ -12,44 +10,18 @@ from astrbot.core.message.components import (
|
||||
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
|
||||
from tests.test_kook.shared import (
|
||||
mock_astrbot_message,
|
||||
mock_file_message,
|
||||
mock_kook_client,
|
||||
mock_record_message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -161,7 +133,7 @@ async def test_kook_event_warp_message(
|
||||
expected_output: OrderMessage,
|
||||
expected_error: type[BaseException] | None,
|
||||
):
|
||||
client = await mock_kook_client(
|
||||
client = mock_kook_client(
|
||||
upload_asset_return,
|
||||
"",
|
||||
)
|
||||
@@ -184,5 +156,20 @@ async def test_kook_event_warp_message(
|
||||
return
|
||||
|
||||
result = await event._wrap_message(1, input_message)
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
expected_output_text: str | list | dict = expected_output.text
|
||||
is_json_text = False
|
||||
try:
|
||||
expected_output_text = json.loads(expected_output_text)
|
||||
is_json_text = True
|
||||
except:
|
||||
pass
|
||||
|
||||
if is_json_text:
|
||||
assert json.loads(result.text) == expected_output_text
|
||||
else:
|
||||
assert result.text == expected_output_text
|
||||
|
||||
assert result.index == expected_output.index
|
||||
assert result.type == expected_output.type
|
||||
assert result.reply_id == expected_output.reply_id
|
||||
|
||||
@@ -15,16 +15,19 @@ from astrbot.core.platform.sources.kook.kook_types import (
|
||||
ImageGroupModule,
|
||||
InviteModule,
|
||||
KmarkdownElement,
|
||||
KookApiResponseBase,
|
||||
KookCardMessage,
|
||||
KookMessageSignal,
|
||||
KookModuleType,
|
||||
KookUserMeResponse,
|
||||
KookUserViewResponse,
|
||||
KookWebsocketEvent,
|
||||
ParagraphStructure,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
KookCardMessageContainer,
|
||||
)
|
||||
from tests.test_kook.shared import TEST_DATA_DIR
|
||||
from tests.test_kook.shared import TEST_DATA_DIR, KookApiDataPath, KookEventDataPath
|
||||
|
||||
|
||||
def test_kook_card_message_container_append():
|
||||
@@ -109,29 +112,31 @@ def test_all_kook_card_type():
|
||||
).to_json(indent=4, ensure_ascii=False)
|
||||
assert json_output == expect_json_data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expected_json_data_filename",
|
||||
"expected_json_data_path",
|
||||
[
|
||||
("kook_ws_event_group_message.json"),
|
||||
("kook_ws_event_hello.json"),
|
||||
("kook_ws_event_message_with_card_1.json"),
|
||||
("kook_ws_event_message_with_card_2.json"),
|
||||
("kook_ws_event_ping.json"),
|
||||
("kook_ws_event_pong.json"),
|
||||
("kook_ws_event_private_message.json"),
|
||||
("kook_ws_event_private_system_message.json"),
|
||||
("kook_ws_event_reconnect_err.json"),
|
||||
("kook_ws_event_resume_ack.json"),
|
||||
("kook_ws_event_resume.json"),
|
||||
|
||||
(KookEventDataPath.GROUP_MESSAGE_WITH_MENTION),
|
||||
(KookEventDataPath.GROUP_MESSAGE),
|
||||
(KookEventDataPath.HELLO),
|
||||
(KookEventDataPath.MESSAGE_WITH_CARD_1),
|
||||
(KookEventDataPath.MESSAGE_WITH_CARD_2),
|
||||
(KookEventDataPath.PING),
|
||||
(KookEventDataPath.PONG),
|
||||
(KookEventDataPath.PRIVATE_MESSAGE),
|
||||
(KookEventDataPath.PRIVATE_SYSTEM_MESSAGE),
|
||||
(KookEventDataPath.RECONNECT_ERR),
|
||||
(KookEventDataPath.RESUME_ACK),
|
||||
(KookEventDataPath.RESUME),
|
||||
(KookEventDataPath.GROUP_SYSTEM_MESSAGE_UPDATE_ROLE),
|
||||
],
|
||||
)
|
||||
def test_websocket_event_type_parse(expected_json_data_filename:str):
|
||||
expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8")
|
||||
def test_websocket_event_type_parse(expected_json_data_path: Path):
|
||||
expected_json_data_str = (expected_json_data_path).read_text(encoding="utf-8")
|
||||
event = KookWebsocketEvent.from_json(
|
||||
expected_json_data_str,
|
||||
)
|
||||
event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False)
|
||||
event_dict = event.to_dict()
|
||||
assert event_dict == json.loads(expected_json_data_str)
|
||||
|
||||
|
||||
@@ -141,8 +146,27 @@ def test_websocket_event_create():
|
||||
data=None,
|
||||
sn=0,
|
||||
)
|
||||
assert ping_data.to_dict(mode="json")== {
|
||||
assert ping_data.to_dict(exclude_none=True, exclude_unset=False) == {
|
||||
"s": KookMessageSignal.PING.value,
|
||||
"sn": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expected_json_data_path, expected_dataclass",
|
||||
[
|
||||
(KookApiDataPath.USER_ME, KookUserMeResponse),
|
||||
(KookApiDataPath.USER_VIEW, KookUserViewResponse),
|
||||
],
|
||||
)
|
||||
def test_api_response_type_parse(
|
||||
expected_json_data_path: Path, expected_dataclass: type[KookApiResponseBase]
|
||||
):
|
||||
expected_json_data_str = (expected_json_data_path).read_text(encoding="utf-8")
|
||||
|
||||
response_body = expected_dataclass.from_json(
|
||||
expected_json_data_str,
|
||||
)
|
||||
|
||||
body_dict = response_body.to_dict()
|
||||
assert body_dict == json.loads(expected_json_data_str)
|
||||
|
||||
359
tests/test_updator_socks.py
Normal file
359
tests/test_updator_socks.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from astrbot.core.zip_updator import RepoZipUpdator
|
||||
|
||||
|
||||
class _FakeJSONResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeStreamResponse:
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
async def aiter_bytes(self, chunk_size: int = 8192):
|
||||
for start in range(0, len(self._payload), chunk_size):
|
||||
yield self._payload[start : start + chunk_size]
|
||||
|
||||
|
||||
class _FakeFailingStreamResponse:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
async def aiter_bytes(self, chunk_size: int = 8192): # noqa: ARG002
|
||||
yield b"partial"
|
||||
raise RuntimeError("stream interrupted")
|
||||
|
||||
|
||||
class _FakeStatusErrorResponse:
|
||||
def __init__(self, status_code: int, body: str, url: str):
|
||||
self._status_code = status_code
|
||||
self._body = body
|
||||
self._url = url
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
request = httpx.Request("GET", self._url)
|
||||
response = httpx.Response(
|
||||
self._status_code,
|
||||
text=self._body,
|
||||
request=request,
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
"status error",
|
||||
request=request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAsyncClientState:
|
||||
json_payload: list[dict] = field(default_factory=list)
|
||||
stream_payload: bytes = b""
|
||||
init_kwargs: dict | None = None
|
||||
requested_urls: list[str] = field(default_factory=list)
|
||||
stream_urls: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _FakeStatusErrorAsyncClient:
|
||||
def __init__(self, response: _FakeStatusErrorResponse):
|
||||
self._response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str):
|
||||
return self._response
|
||||
|
||||
|
||||
class _FakeFailingStreamAsyncClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
def stream(self, method: str, url: str): # noqa: ARG002
|
||||
return _FakeFailingStreamResponse()
|
||||
|
||||
|
||||
def _build_fake_httpx_module(state: _FakeAsyncClientState) -> SimpleNamespace:
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, **kwargs):
|
||||
state.init_kwargs = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str):
|
||||
state.requested_urls.append(url)
|
||||
return _FakeJSONResponse(state.json_payload)
|
||||
|
||||
def stream(self, method: str, url: str):
|
||||
assert method == "GET"
|
||||
state.stream_urls.append(url)
|
||||
return _FakeStreamResponse(state.stream_payload)
|
||||
|
||||
return SimpleNamespace(
|
||||
AsyncClient=_FakeAsyncClient,
|
||||
HTTPStatusError=httpx.HTTPStatusError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_async_client_state() -> _FakeAsyncClientState:
|
||||
return _FakeAsyncClientState()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_async_client_state: _FakeAsyncClientState,
|
||||
) -> None:
|
||||
import astrbot.core.zip_updator as zip_updator_module
|
||||
|
||||
fake_async_client_state.json_payload = [
|
||||
{
|
||||
"name": "AstrBot v4.23.2",
|
||||
"published_at": "2026-04-16T00:00:00Z",
|
||||
"body": "fix updater socks proxy support",
|
||||
"tag_name": "v4.23.2",
|
||||
"zipball_url": "https://example.com/astrbot.zip",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module,
|
||||
"aiohttp",
|
||||
SimpleNamespace(
|
||||
ClientSession=lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError(
|
||||
"fetch_release_info should not use aiohttp.ClientSession"
|
||||
)
|
||||
)
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module,
|
||||
"httpx",
|
||||
_build_fake_httpx_module(fake_async_client_state),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
release_info = await RepoZipUpdator().fetch_release_info(
|
||||
"https://api.soulter.top/releases"
|
||||
)
|
||||
|
||||
assert release_info == [
|
||||
{
|
||||
"version": "AstrBot v4.23.2",
|
||||
"published_at": "2026-04-16T00:00:00Z",
|
||||
"body": "fix updater socks proxy support",
|
||||
"tag_name": "v4.23.2",
|
||||
"zipball_url": "https://example.com/astrbot.zip",
|
||||
}
|
||||
]
|
||||
assert fake_async_client_state.requested_urls == ["https://api.soulter.top/releases"]
|
||||
assert fake_async_client_state.init_kwargs is not None
|
||||
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
|
||||
assert fake_async_client_state.init_kwargs["timeout"] == 30.0
|
||||
assert fake_async_client_state.init_kwargs["trust_env"] is True
|
||||
assert fake_async_client_state.init_kwargs["verify"] == certifi.where()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_from_repo_url_uses_httpx_stream_for_zip_download(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
fake_async_client_state: _FakeAsyncClientState,
|
||||
) -> None:
|
||||
import astrbot.core.zip_updator as zip_updator_module
|
||||
|
||||
fake_async_client_state.stream_payload = b"zip-data"
|
||||
|
||||
async def fake_fetch_release_info(self, url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v4.23.2",
|
||||
"published_at": "2026-04-16T00:00:00Z",
|
||||
"body": "fix updater socks proxy support",
|
||||
"tag_name": "v4.23.2",
|
||||
"zipball_url": "https://example.com/archive.zip",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(RepoZipUpdator, "fetch_release_info", fake_fetch_release_info)
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module,
|
||||
"download_file",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("download_from_repo_url should not use aiohttp download_file")
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module,
|
||||
"httpx",
|
||||
_build_fake_httpx_module(fake_async_client_state),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
target_path = tmp_path / "AstrBot"
|
||||
await RepoZipUpdator().download_from_repo_url(
|
||||
str(target_path),
|
||||
"https://github.com/AstrBotDevs/AstrBot",
|
||||
)
|
||||
|
||||
assert (tmp_path / "AstrBot.zip").read_bytes() == b"zip-data"
|
||||
assert fake_async_client_state.stream_urls == ["https://example.com/archive.zip"]
|
||||
assert fake_async_client_state.init_kwargs is not None
|
||||
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
|
||||
assert fake_async_client_state.init_kwargs["timeout"] == 1800.0
|
||||
assert fake_async_client_state.init_kwargs["trust_env"] is True
|
||||
assert fake_async_client_state.init_kwargs["verify"] == certifi.where()
|
||||
|
||||
|
||||
def test_create_httpx_client_uses_custom_verify_setting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_async_client_state: _FakeAsyncClientState,
|
||||
) -> None:
|
||||
import astrbot.core.zip_updator as zip_updator_module
|
||||
|
||||
custom_verify = "/tmp/custom-ca.pem"
|
||||
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module,
|
||||
"httpx",
|
||||
_build_fake_httpx_module(fake_async_client_state),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
RepoZipUpdator(verify=custom_verify)._create_httpx_client(timeout=45.0)
|
||||
|
||||
assert fake_async_client_state.init_kwargs is not None
|
||||
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
|
||||
assert fake_async_client_state.init_kwargs["timeout"] == 45.0
|
||||
assert fake_async_client_state.init_kwargs["trust_env"] is True
|
||||
assert fake_async_client_state.init_kwargs["verify"] == custom_verify
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_release_info_logs_status_code_and_truncated_body_on_http_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import astrbot.core.zip_updator as zip_updator_module
|
||||
|
||||
url = "https://api.soulter.top/releases"
|
||||
body = "x" * 1005
|
||||
log_messages: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
RepoZipUpdator,
|
||||
"_create_httpx_client",
|
||||
staticmethod(
|
||||
lambda timeout=30.0: _FakeStatusErrorAsyncClient( # noqa: ARG005
|
||||
_FakeStatusErrorResponse(502, body, url)
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module.logger,
|
||||
"error",
|
||||
lambda message: log_messages.append(message),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="解析版本信息失败"):
|
||||
await RepoZipUpdator().fetch_release_info(url)
|
||||
|
||||
assert any("状态码: 502" in message for message in log_messages)
|
||||
assert any("内容: " in message for message in log_messages)
|
||||
assert any("...[truncated]" in message for message in log_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_removes_partial_file_when_stream_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
RepoZipUpdator,
|
||||
"_create_httpx_client",
|
||||
staticmethod(
|
||||
lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005
|
||||
),
|
||||
)
|
||||
|
||||
target_path = tmp_path / "partial.zip"
|
||||
|
||||
with pytest.raises(RuntimeError, match="stream interrupted"):
|
||||
await RepoZipUpdator()._download_file(
|
||||
"https://example.com/archive.zip",
|
||||
str(target_path),
|
||||
)
|
||||
|
||||
assert not target_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_logs_url_and_target_path_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
import astrbot.core.zip_updator as zip_updator_module
|
||||
|
||||
url = "https://example.com/archive.zip"
|
||||
target_path = tmp_path / "logged-partial.zip"
|
||||
log_messages: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
RepoZipUpdator,
|
||||
"_create_httpx_client",
|
||||
staticmethod(
|
||||
lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zip_updator_module.logger,
|
||||
"error",
|
||||
lambda message: log_messages.append(message),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stream interrupted"):
|
||||
await RepoZipUpdator()._download_file(url, str(target_path))
|
||||
|
||||
assert any(url in message for message in log_messages)
|
||||
assert any(str(target_path) in message for message in log_messages)
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError
|
||||
from astrbot.core.db.po import CronJob
|
||||
|
||||
|
||||
@@ -190,24 +190,25 @@ class TestAddActiveJob:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_active_job_run_once(self, cron_manager, mock_db, sample_cron_job):
|
||||
"""Test adding a run-once active job."""
|
||||
"""Test adding a run-once active job with an invalid returned job."""
|
||||
sample_cron_job.job_type = "active_agent"
|
||||
sample_cron_job.run_once = True
|
||||
mock_db.create_cron_job.return_value = sample_cron_job
|
||||
|
||||
run_at = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
|
||||
result = await cron_manager.add_active_job(
|
||||
name="Test Run Once Job",
|
||||
cron_expression=None,
|
||||
payload={"session": "test:group:123"},
|
||||
run_once=True,
|
||||
run_at=run_at,
|
||||
)
|
||||
with pytest.raises(CronJobSchedulingError, match="Invalid isoformat string"):
|
||||
await cron_manager.add_active_job(
|
||||
name="Test Run Once Job",
|
||||
cron_expression=None,
|
||||
payload={"session": "test:group:123"},
|
||||
run_once=True,
|
||||
run_at=run_at,
|
||||
)
|
||||
|
||||
assert result == sample_cron_job
|
||||
call_kwargs = mock_db.create_cron_job.call_args.kwargs
|
||||
assert call_kwargs["run_once"] is True
|
||||
assert call_kwargs["payload"]["run_at"] == run_at.isoformat()
|
||||
|
||||
|
||||
class TestUpdateJob:
|
||||
|
||||
75
tests/unit/test_document_storage_fts.py
Normal file
75
tests/unit/test_document_storage_fts.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_storage_fts_insert_search_and_delete(tmp_path):
|
||||
storage = DocumentStorage(str(tmp_path / "doc.db"))
|
||||
await storage.initialize()
|
||||
|
||||
assert storage.fts5_available is True
|
||||
|
||||
await storage.insert_documents_batch(
|
||||
doc_ids=["chunk-1", "chunk-2"],
|
||||
texts=["AstrBot 知识库召回性能优化", "FAISS 向量检索"],
|
||||
metadatas=[
|
||||
{"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
|
||||
{"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 1},
|
||||
],
|
||||
)
|
||||
|
||||
results = await storage.search_sparse(["知识库"], limit=10)
|
||||
|
||||
assert results is not None
|
||||
assert [result["doc_id"] for result in results] == ["chunk-1"]
|
||||
|
||||
await storage.delete_document_by_doc_id("chunk-1")
|
||||
results = await storage.search_sparse(["知识库"], limit=10)
|
||||
|
||||
assert results == []
|
||||
|
||||
await storage.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_storage_fts_rebuilds_existing_documents(tmp_path):
|
||||
storage = DocumentStorage(str(tmp_path / "doc.db"))
|
||||
await storage.initialize()
|
||||
|
||||
storage.fts5_available = False
|
||||
await storage.insert_document(
|
||||
doc_id="legacy-chunk",
|
||||
text="legacy 知识库 文本",
|
||||
metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
|
||||
)
|
||||
|
||||
storage.fts5_available = True
|
||||
storage._fts_index_ready = False
|
||||
|
||||
results = await storage.search_sparse(["知识库"], limit=10)
|
||||
|
||||
assert results is not None
|
||||
assert [result["doc_id"] for result in results] == ["legacy-chunk"]
|
||||
|
||||
await storage.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_storage_fts_delete_skips_missing_fts_row(tmp_path):
|
||||
storage = DocumentStorage(str(tmp_path / "doc.db"))
|
||||
await storage.initialize()
|
||||
|
||||
storage.fts5_available = False
|
||||
await storage.insert_document(
|
||||
doc_id="legacy-chunk",
|
||||
text="legacy 知识库 文本",
|
||||
metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
|
||||
)
|
||||
|
||||
storage.fts5_available = True
|
||||
await storage.delete_document_by_doc_id("legacy-chunk")
|
||||
|
||||
assert await storage.get_document_by_doc_id("legacy-chunk") is None
|
||||
|
||||
await storage.close()
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
|
||||
from astrbot.core.exceptions import KnowledgeBaseUploadError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -18,3 +19,28 @@ async def test_insert_batch_skips_empty_contents() -> None:
|
||||
vec_db.embedding_provider.get_embeddings_batch.assert_not_awaited()
|
||||
vec_db.document_storage.insert_documents_batch.assert_not_awaited()
|
||||
vec_db.embedding_storage.insert_batch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_batch_raises_friendly_error_for_embedding_count_mismatch() -> (
|
||||
None
|
||||
):
|
||||
vec_db = FaissVecDB.__new__(FaissVecDB)
|
||||
vec_db.embedding_provider = AsyncMock()
|
||||
vec_db.embedding_provider.get_embeddings_batch.return_value = [[0.1, 0.2]]
|
||||
vec_db.document_storage = AsyncMock()
|
||||
vec_db.embedding_storage = AsyncMock()
|
||||
vec_db.embedding_storage.dimension = 2
|
||||
|
||||
with pytest.raises(KnowledgeBaseUploadError) as exc_info:
|
||||
await FaissVecDB.insert_batch(
|
||||
vec_db,
|
||||
contents=["chunk-1", "chunk-2"],
|
||||
metadatas=[{}, {}],
|
||||
ids=["doc-1", "doc-2"],
|
||||
)
|
||||
|
||||
assert "向量化失败" in str(exc_info.value)
|
||||
assert "期望 2,实际 1" in str(exc_info.value)
|
||||
vec_db.document_storage.insert_documents_batch.assert_not_awaited()
|
||||
vec_db.embedding_storage.insert_batch.assert_not_awaited()
|
||||
|
||||
93
tests/unit/test_sparse_retriever.py
Normal file
93
tests/unit/test_sparse_retriever.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever
|
||||
|
||||
|
||||
def make_doc(chunk_id: str, text: str, chunk_index: int = 0) -> dict:
|
||||
return {
|
||||
"doc_id": chunk_id,
|
||||
"text": text,
|
||||
"metadata": json.dumps(
|
||||
{
|
||||
"chunk_index": chunk_index,
|
||||
"kb_doc_id": f"doc-{chunk_index}",
|
||||
"kb_id": "kb-1",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class FTSStorage:
|
||||
def __init__(self):
|
||||
self.search_sparse_calls = 0
|
||||
self.get_documents_calls = 0
|
||||
|
||||
async def search_sparse(self, query_tokens: list[str], limit: int):
|
||||
self.search_sparse_calls += 1
|
||||
assert query_tokens == ["apple"]
|
||||
assert limit == 1
|
||||
return [
|
||||
{
|
||||
**make_doc("chunk-1", "apple banana", 0),
|
||||
"score": -1.0,
|
||||
},
|
||||
]
|
||||
|
||||
async def get_documents(self, *args, **kwargs):
|
||||
self.get_documents_calls += 1
|
||||
return []
|
||||
|
||||
|
||||
class FallbackStorage:
|
||||
def __init__(self):
|
||||
self.search_sparse_calls = 0
|
||||
self.get_documents_calls = 0
|
||||
|
||||
async def search_sparse(self, query_tokens: list[str], limit: int):
|
||||
self.search_sparse_calls += 1
|
||||
return None
|
||||
|
||||
async def get_documents(self, metadata_filters: dict, limit: int | None, offset):
|
||||
self.get_documents_calls += 1
|
||||
return [
|
||||
make_doc("chunk-1", "apple banana", 0),
|
||||
make_doc("chunk-2", "orange pear", 1),
|
||||
make_doc("chunk-3", "grape melon", 2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sparse_retriever_uses_fts5_when_available():
|
||||
storage = FTSStorage()
|
||||
vec_db = SimpleNamespace(document_storage=storage)
|
||||
retriever = SparseRetriever(kb_db=None)
|
||||
|
||||
results = await retriever.retrieve(
|
||||
query="apple",
|
||||
kb_ids=["kb-1"],
|
||||
kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}},
|
||||
)
|
||||
|
||||
assert [result.chunk_id for result in results] == ["chunk-1"]
|
||||
assert storage.search_sparse_calls == 1
|
||||
assert storage.get_documents_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sparse_retriever_falls_back_to_bm25_when_fts5_is_unavailable():
|
||||
storage = FallbackStorage()
|
||||
vec_db = SimpleNamespace(document_storage=storage)
|
||||
retriever = SparseRetriever(kb_db=None)
|
||||
|
||||
results = await retriever.retrieve(
|
||||
query="apple",
|
||||
kb_ids=["kb-1"],
|
||||
kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}},
|
||||
)
|
||||
|
||||
assert [result.chunk_id for result in results] == ["chunk-1"]
|
||||
assert storage.search_sparse_calls == 1
|
||||
assert storage.get_documents_calls == 1
|
||||
Reference in New Issue
Block a user