Compare commits

...

35 Commits

Author SHA1 Message Date
邹永赫
56ada40314 style: format provider command 2026-04-21 09:31:33 +09:00
Soulter
fb43c06123 feat: re-establishing /provider as a built-in command 2026-04-20 17:41:57 +08:00
Soulter
00689604b4 chore: bump version to 4.23.2 2026-04-19 17:50:03 +08:00
QAQneko
960bc21c53 fix: resolve EmptyModelOutputError and enhance tool fallback robustness (#7375)
Improve robustness of tool call handling in OpenAI completions and agent tool loop by avoiding premature filtering and surfacing clearer errors when tools are missing.

* Refactor tool call argument handling in openai_source.py

* Improve error logging for missing tools

Log available tools when a specified tool is not found.
2026-04-19 17:12:12 +08:00
shuiping233
1199b704a8 feat: implements support for KOOK role mentions (#7626)
* feat: 实现kook适配器响应`@`角色(role)的能力

* refactor: kook适配器处理role时,`At`组件保留`@`角色的名称而不是id

* fix: kook适配器处理role时,role_id的判断问题

* refactor: 移除kook适配器中的一个# type: ignore

* fix: 修复kook适配器 role mention转换成`At`组件时保留不是角色名称的bug;

* unittest: 给kook适配器添加带有role mention的事件消息的单测,并添加消息组件转换判断单测

* unittest: 部分重构test_kook_event.py和test_kook_types.py 单测

* unittest: 添加kook适配器的 `user/me` `user/view` 接口响应数据验证单测

* fix: 修复kook适配器接收频道权限更新消息会报错的bug

* fix:  不额外处理kook的道具消息

* fix: 使用async with self._http_client.get

* refactor: kook适配器转换文本内容为消息组件时,只strip mention之间的空格

* fix: 修复 role_mention_counter 计数不正确的问题

* fix: 修复kook适配器发送卡片失败的问题;区分两类kook 数据类的to_dict to_json行为

* chore: 添加注释

* refactor: 重构kook适配器的角色缓存功能,使其无锁,性能更好且具备良好的重试机制

* refactor: kook适配器的channel_id 改为 guild_id

* feat: kook适配器响应频道角色更新事件时不再清空整个角色id缓存,而是只清理特定频道的角色id缓存

* unittest: 添加kook适配器的update_role事件的数据类验证单测

* refactor: 补上了一些打印的日志消息文本

refactor: 补上了一些打印的日志消息文本

refactor: 补上了一些打印的日志消息文本

* refactor: 修复kook适配器潜在可能的类型问题

* refactor: `clean_roles_cache`重命名为`clear_guild_roles_cache`
2026-04-19 14:18:16 +08:00
千岚之夏
b40bcbbd86 fix: resolve relative file paths within a local workspace root for the SendMessageToUserTool (#7668)
* fix: resolve relative file paths against workspace directory

* fix: add path normalization and security check for workspace resolution

* chore: remove temp PR body file

* chore: added some comments

Added comments to clarify path resolution logic.

* Update astrbot/core/tools/message_tools.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Test User <test@test.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-19 14:09:02 +08:00
Soulter
fd2ca702d7 fix: remove default value for injected isDark in ThemeAwareMarkdownCodeBlock 2026-04-19 13:19:08 +08:00
MagicSun7940
b2a95713f8 修复了使用 Bocha 搜索时报错 "Can not decode content-encoding: br"的bug (#7655)
* 修复了使用 Bocha 搜索时报错 "Can not decode content-encoding: br"的bug

* 添加了注释,解释为什么要限制 Accept-Encoding,方便以后的维护者理解这是针对 aiohttp brotli bug 的临时规避方案。
2026-04-19 13:04:20 +08:00
Strands
fbe9a38c42 fix(dashboard): propagate dark mode to code blocks inside list items (#7667) 2026-04-19 13:01:33 +08:00
bobo-xxx
29a449f90d fix: handle rate_limit_count=0 to prevent IndexError (#7635)
* fix: handle rate_limit_count=0 to prevent IndexError

* Update astrbot/core/pipeline/rate_limit_check/stage.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-18 20:37:36 +08:00
Yufeng He
e98eb92b5f fix: prevent Telegram media group exceptions from being silently swallowed (#7537)
* fix: prevent Telegram media group exceptions from being silently swallowed

process_media_group() is invoked by APScheduler via add_job(). If
convert_message() or handle_msg() raises (e.g. get_file() network
timeout, file download failure), APScheduler catches the exception
internally and only logs it through its own logger, which is often
not configured in AstrBot. The result is that the media group
silently disappears with no trace in the application logs.

Two changes:
- Wrap the body of process_media_group() in try/except so failures
  are logged through AstrBot's own logger with full traceback.
- Register an EVENT_JOB_ERROR listener on the scheduler as a
  safety net, so any future scheduled job that throws will also
  surface in the logs.

Fixes #7512

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-18 20:31:33 +08:00
Soulter
352455197d feat: implement FTS5 support in knowledge base sparse retrieving stage (#7648)
* feat: implement FTS5 support in DocumentStorage and SparseRetriever with tokenizer enhancements

* feat: optimize FTS row handling in DocumentStorage and update query tokenization in SparseRetriever
2026-04-18 19:57:27 +08:00
時壹
47f78be378 fix: display cron last_run_at in local timezone (#7625) 2026-04-17 18:36:47 +08:00
SaintaToken
a1a7de1c57 fix: correct minor text inconsistencies in README files and document (#7602)
* fix: correct minor text inconsistencies in README files and documentation

* Update README_zh.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* feat: add buffered intermediate messages for non-streaming agent loop

* Revert "feat: add buffered intermediate messages for non-streaming agent loop"

This reverts commit 803762c99a.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-17 17:20:13 +08:00
千岚之夏
0ca6ba91b1 feat: add MiniMax Token Plan provider with hardcoded model list (#7609)
* feat: add MiniMax Token Plan provider with hardcoded model list (fix #7585)

- Add new provider 'minimax_token_plan' for MiniMax Token Plan users
- Inherit ProviderAnthropic to reuse all chat/completion logic
- Hardcode api_base to https://api.minimaxi.com/anthropic
- get_models() returns hardcoded list: MiniMax-M2.7, M2.5, M2.1, M2
- Highspeed models excluded (require premium tier)
- Reason for hardcoding: Token Plan API does not expose /models endpoint
- Fixes: https://github.com/AstrBotDevs/AstrBot/issues/7585

* fix: remove api_base from config template and add model validation

- Remove api_base from default_config_tmpl (always overridden, misleading)
- Add model validation against MINIMAX_TOKEN_PLAN_MODELS
- Raise clear ValueError if user configures an unsupported model

Addressed Sourcery AI review comments.

* fix: use custom_headers for Bearer token auth instead of auth_header

MiniMax Token Plan requires Authorization: Bearer <token> header.
Use custom_headers to inject the correct auth header instead of
the non-functional auth_header key.

Addressed Gemini Code Assist review comment.

* fix: update MiniMax Token Plan provider adapter and documentation to English

* feat: add MiniMax Token Plan configuration and icon support

* feat: remove default configuration template from MiniMax Token Plan provider adapter

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-17 16:48:52 +08:00
エイカク
5be6536f0e fix: support SOCKS proxies in updater requests (#7615)
* fix: support SOCKS proxies in updater requests

* fix: log updater HTTP status details

* fix: clean partial updater downloads on failure

* test: lock updater httpx client options

* refactor: harden updater httpx configuration
2026-04-17 13:13:21 +09:00
Soulter
087c793615 revert "fix: scss import warning (#7528)" (#7616)
This reverts commit ee85a4e50f.
2026-04-17 11:58:47 +08:00
Hongbro886
89096411d2 docs: correct documentation URL from astrbot.app to docs.astrbot.app (#7612)
* docs: correct documentation URL from astrbot.app to docs.astrbot.app

* docs: correct documentation URL from astrbot.app to docs.astrbot.app
2026-04-17 11:07:48 +09:00
Hongbro886
22e8cbd10d fix: return an explicit erro from the cron tool when scheduling a task fails instead of processing silently(#7513)
* fix: 定时任务创建失败时返回错误信息而非静默处理

* fix: test and format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-16 20:14:55 +08:00
Kangyang Ji
ee85a4e50f fix: scss import warning (#7528)
* chore(dashboard): 将 Sass @import 迁移到 @use

Dart Sass 3.0.0 将移除 @import,迁移到 @use 以消除弃用警告

* add new import into style.scss

---------

Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
2026-04-16 20:05:37 +08:00
SweetenedSuzuka
a8660ff21e fix(weixin_oc): persist context_token for proactive cron sends (#7595)
* fix(weixin_oc): persist context_token for proactive cron sends

* test(weixin_oc): add safety coverage for context token persistence

* fix(weixin_oc): address review on context token state

* chore: delete tests/unit/test_weixin_oc_adapter_state.py

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-04-16 20:03:59 +08:00
Soulter
469f498428 fix: increase anthropic default max tokens (#7593) 2026-04-16 14:51:36 +08:00
XiaoYang
34cf4014e6 fix: prevent numeric input from resetting to zero on blur without edit (#7560)
When a numeric input field was focused but not edited, the blur handler
called toNumber(null) which returned 0 via parseFloat(null) → NaN → 0.
Now we skip emitting the update when numericTemp is null (no edits made).
2026-04-16 08:48:13 +08:00
時壹
7c39abc6b5 fix(dashboard): resolve chat attachment 401 (#7569)
* fix(dashboard): resolve chat attachment 401 by restoring axios blob URL fetch

* chore: cache blob URL promises to prevent duplicate requests and memory leaks
2026-04-15 15:52:14 +08:00
Soulter
cb91dfb6f7 docs: update installation instructions to require Python 3.12 for uv deployment 2026-04-14 19:50:54 +08:00
Soulter
49531da91d feat: add on_agent_begin, on_using_llm_tool, on_llm_tool_respond, on_agent_done event hooks (#7540)
* feat: add on_agent_begin, on_using_llm_tool, on_llm_tool_respond, on_agent_done event hooks

* docs: add version requirement for event hooks in message event guide

* Update astrbot/core/star/star_handler.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update astrbot/core/astr_agent_hooks.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* feat: rename event types to include 'Event' suffix for consistency

* chore: ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 18:55:05 +08:00
若月千鸮
625eab223f feat: enable shiki highlighting for t2i templates and add a template (#7501)
* fix: enable shiki highlighting for t2i templates

* fix: t2i templates cr

* feat: add new t2i template astrbot_vitepress.html
2026-04-14 16:46:32 +08:00
Ruochen Pan
207eb34ba2 fix: improve error handling for knowledge base upload (#7536)
* fix: improve error handling for knowledge base upload

- Log details field in KnowledgeBaseUploadError for better debugging
- Distinguish between empty pre-chunked text and empty chunking result
  with appropriate error messages

* style: format code
2026-04-14 16:44:14 +08:00
Gargantua
cc72c01c0e fix: improve knowledge base upload error messages (#7534)
* fix: improve knowledge base upload error messages

* fix: deduplicate knowledge base upload logs

* fix: handle type errors in kb embedding validation
2026-04-14 16:27:06 +08:00
Kangyang Ji
11dedf3802 improve dashboard and docs ci to pnpm and cache (#7522) 2026-04-14 16:10:43 +08:00
Soulter
631e5fe152 docs: update supported IM platforms 2026-04-14 10:27:47 +08:00
dependabot[bot]
b342cf9997 chore(deps): bump the github-actions group with 3 updates (#7524)
Bumps the github-actions group with 3 updates: [docker/build-push-action](https://github.com/docker/build-push-action), [actions/github-script](https://github.com/actions/github-script) and [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `docker/build-push-action` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v7.0.0...v7.1.0)

Updates `actions/github-script` from 8 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

Updates `pnpm/action-setup` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 08:48:15 +08:00
Soulter
1292faa446 chore: bump version to 4.23.1 2026-04-13 23:39:34 +08:00
Soulter
abd11d5579 fix: routing not displayed when session id includes : (#7517)
* fix: routing not displayed when session id includes `:`

fixes: #7515
2026-04-13 23:32:40 +08:00
Soulter
afeda9b82a fix: downgrade python-ripgrep version to 0.0.8 in dependencies (#7514)
* fix: downgrade python-ripgrep version to 0.0.8 in dependencies

* fix: update smoke test workflow to support multiple OS and Python versions
2026-04-13 20:54:18 +08:00
91 changed files with 5126 additions and 693 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) | コミュニティ |

View File

@@ -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) | Сообщество |
## Поддерживаемые сервисы моделей

View File

@@ -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) | 社群維護 |
## 支援的模型服務

View File

@@ -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 应用。
![landingpage](https://github.com/user-attachments/assets/45fc5699-cddf-4e21-af35-13040706f6c0)
@@ -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) | 社区维护 |
## 支持的模型提供商

View File

@@ -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",

View File

@@ -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",
]

View 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."))

View File

@@ -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:

View File

@@ -1 +1 @@
__version__ = "4.23.0"
__version__ = "4.23.2"

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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}")

View File

@@ -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

View 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)

View File

@@ -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

View File

@@ -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()}")

View File

@@ -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:

View 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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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 = ""

View File

@@ -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":

View File

@@ -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(

View 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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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 参数。

View File

@@ -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 工具

View File

@@ -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:

View File

@@ -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})."

View File

@@ -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:

View File

@@ -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(

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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">&gt; 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>

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

@@ -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(

View File

@@ -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` 结构

View File

@@ -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 ""

View File

@@ -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),
},
)
# 更新任务完成状态

View File

@@ -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
View 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
View 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))

View File

@@ -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/",

View File

@@ -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:

View 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);
});

View File

@@ -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)
};
},
// 获取消息类型标签

View File

@@ -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"

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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',

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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` 钩子。

View File

@@ -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` 钩子。

View File

@@ -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 应用。
## 文档概览

View File

@@ -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]

View File

@@ -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

View 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())

View File

@@ -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."""

View 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
}
}

View 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
}
}

View File

@@ -26,7 +26,7 @@
"banner": "",
"nickname": "some_username",
"roles": [
63724577
63423577
],
"is_vip": false,
"vip_amp": false,

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View 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)

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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:

View 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()

View File

@@ -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()

View 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