Compare commits

...

21 Commits

Author SHA1 Message Date
Soulter 8faaa4b2be feat: add error handling for disabled sandbox runtime in get_booter function 2026-03-12 00:28:45 +08:00
Soulter 7f5bd942b3 feat: add video message support and enhance message type descriptions in SendMessageToUserTool 2026-03-12 00:26:56 +08:00
Soulter e254caf82d fix(docs): add official developer group ID to multiple language READMEs and enhance regex description in config metadata 2026-03-11 21:26:11 +08:00
Soulter 7efcd242d6 fix(docs): update edit link patterns and remove obsolete repository reference 2026-03-11 17:42:42 +08:00
JIANG Zijun 5d811d3949 fix: Persist Discord pre-ack emoji config across restart by adding missing default key (#6031)
* Initial plan

* fix: add discord default platform_specific pre-ack config

Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>

* Delete tests/unit/test_config.py

we don't need to add tests

* fix: use 🤔 as default discord pre-ack emoji

Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>

* add back old test config

* doc: discord pre-ack-emoji doc

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>
2026-03-11 16:41:08 +08:00
Flartiny 8e6aaee10c fix(webui): unify search input clear behavior (#6017)
* fix(webui): unify search input clear behavior

* fix: centralize search input normalization
2026-03-11 15:14:16 +08:00
エイカク 6da59cfb07 fix: 插件依赖自动安装逻辑与 Dashboard 安装体验优化 (#5954)
* fix: install plugin requirements before first load

* fix: handle pip option arguments correctly

* fix: harden pip install input parsing

* refactor: simplify pip install input parsing

* fix: align plugin dependency install handling

* fix: respect configured pip index overrides

* test: parameterize plugin dependency install flows

* refactor: simplify multiline pip input parsing

* fix: install plugin dependencies before loading

* fix: protect core dependencies from downgrades and simplify package input splitting

* fix: enhance dependency conflict reporting and improve user-facing warnings

* refactor: preserve pip log indentation and fix CodeQL URL sanitization alert

* fix: explicit re-export for DependencyConflictError to satisfy ruff F401

* test: enhance index override verification in pip installer tests

* fix: correctly map pip ERROR and WARNING outputs to proper log levels

* refactor: show specific version conflicts in DependencyConflictError and revert log level mapping

* refactor: simplify install() by decoupling pip logging, failure classification and constraint file management

* refactor: further simplify pip installer and requirement parsing logic

* refactor: simplify dependency installation logic and improve circular requirement reporting

* style: organize imports in astrbot/core/__init__.py

* refactor: optimize requirement parsing efficiency and flatten pip installer API

* style: fix import sorting in astrbot/core/__init__.py

* refactor: consolidate requirement parsing, optimize core protection, and improve exception propagation

* fix: preserve valid pip requirement parsing

* fix: skip empty pip installs and preserve blank output

* chore: normalize gitignore entry style

* fix: tighten pip trust and requirement parsing

* refactor: centralize pip install parsing and failure handling

* fix: redact pip argv credentials in logs

* fix: surface plugin dependency install errors

* fix: cache core constraints and clarify requirement installs

* fix: harden pip requirement parsing for plugin installs

* fix: simplify pip installer parsing internals

* fix: tighten pip installer parsing and redaction

* refactor: simplify plugin dependency install flow

* fix: preserve core constraint conflict errors

* fix: harden pip installer fallback resolution

* refactor: split pip requirement and constraint helpers

* refactor: simplify pip installer helper flow

* refactor: streamline requirement precheck helpers

* refactor: clarify core constraint resolution

* fix: surface pip install failures explicitly

* refactor: separate pip conflict context parsing

* fix: harden core constraint resolution

* test: cover pip installer failure call sites

* refactor: remove dead requirements fallback helper

* refactor: narrow core constraint error handling

* refactor: unify requirement iteration

* refactor: share requirement name parsing

* test: align pip helper coverage

* fix: bind pip output limit at runtime

* refactor: reuse core requirement parser for tokens
2026-03-11 14:21:55 +09:00
Soulter 10ceacfbb1 chore: bump version to 4.19.5 2026-03-11 00:17:14 +08:00
ChuwuYo 66f5ccd902 fix: add file size validation to TTS provider test and MiniMax empty audio detection (#5999)
- Add audio data validation in MiniMax TTS get_audio() method to detect empty responses
- Validate generated audio file size in TTSProvider.test() to ensure valid output
- Provide detailed error messages guiding users to check group_id configuration
- Auto-cleanup test audio files after validation
- Fixes issue where 0KB audio files would pass TTS detection when group_id is not configured
2026-03-11 00:07:19 +08:00
Soulter 3379587223 feat(mcp): enhance logging and initialize MCP clients in background (#5993)
* feat(mcp): enhance logging and initialize MCP clients in background

fixes: #5777

* rf

* fix(mcp): simplify MCP client initialization in background

* fix(mcp): update error message for MCP background initialization failure
2026-03-11 00:00:48 +08:00
邹永赫 e25a1a42cf Revert "fix: clarify missing MCP stdio command errors (#5992)"
This reverts commit 0c771e4a77.
2026-03-11 00:08:06 +09:00
エイカク 0c771e4a77 fix: clarify missing MCP stdio command errors (#5992)
* fix: clarify missing MCP stdio command errors

* refactor: tighten MCP error presentation helpers

* fix: improve MCP test connection feedback

* fix: structure MCP test connection errors

* refactor: share MCP test error codes
2026-03-10 23:05:50 +09:00
camera-2018 ec21cb13d3 feat(lark): supports CardKit streaming output for feishu (#5777)
* feat(lark): 支持飞书 CardKit 流式输出

* refactor(lark): extract streaming fallback logic and deduplicate final text update

* fix(lark): 修复流式输出竞态条件及增强健壮性

- 修复 sender loop 中 delta 快照竟态: await 期间 delta 被 generator
  更新导致 last_sent 记录了未发送的值, 造成输出卡在最后一段
- send_streaming 入口增加 platform_meta 守卫, 未启用时直接回退
- _fallback_send_streaming 移除对已耗尽 generator 的 super() 调用,
  改为内联父类副作用 (Metric.upload + _has_send_oper)
- Metric.upload 统一改为 await, 确保指标上报在方法返回前完成
- 装饰器 support_streaming_message 改为 False, 与 meta() 动态配置对齐
- i18n hint 补充提示: 需在「AI 配置 → 其他配置」中开启流式输出

* chore(lark): 收口配置

* docs(lark): update streaming output instructions and client version requirements

---------

Co-authored-by: bread-ovo <2570425204@qq.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-10 19:40:46 +08:00
Soulter 1d26b96d90 fix(workflow): update build-docs.yml to trigger on version tags instead of master branch 2026-03-10 17:16:56 +08:00
一袋米要扛幾樓 be017c87f4 fix: 前端修正切換到 chat 切換後回 welcome 的配置保存最終切換頁面 (#5792)
* 前端修正切換到chat切換後回 welcome 的配置保存最終切換頁面

* 修復 SSR 不含localStorage 環境驗證
2026-03-10 17:14:28 +08:00
lustresixx 23fffa95c8 fix(provider): support 84-char Azure TTS subscription keys (#5813)
* fix(provider): support 84-char Azure TTS subscription keys

* test(provider): add negative Azure TTS key validation cases

* chore: delete test

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-10 17:09:13 +08:00
dependabot[bot] 5b303e2e6d chore(deps): bump the github-actions group with 7 updates (#5966)
Bumps the github-actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-node](https://github.com/actions/setup-node) | `2` | `6` |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-node` from 2 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v6)

Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  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-03-10 16:56:52 +08:00
Soulter fc33b3eb68 docs: transfer AstrBotDevs/AstrBot-docs to AstrBotDevs/AstrBot (#5960)
* docs: transfer AstrBotDevs/AstrBot-docs to AstrBotDevs/AstrBot
* refactor: reorder imports and improve type hints in sync_docs_to_wiki.py and upload_doc_images_to_r2.py
* feat: add GitHub Actions workflow to sync wiki with documentation

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: anka-afk <110004162+anka-afk@users.noreply.github.com>
Co-authored-by: zouyonghe <62183434+zouyonghe@users.noreply.github.com>
Co-authored-by: shuiping233 <49360196+shuiping233@users.noreply.github.com>
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
Co-authored-by: Sjshi763 <179909421+Sjshi763@users.noreply.github.com>
Co-authored-by: xiewoc <70128845+xiewoc@users.noreply.github.com>
Co-authored-by: QingFeng-awa <151742581+QingFeng-awa@users.noreply.github.com>
Co-authored-by: PaloMiku <96452465+PaloMiku@users.noreply.github.com>
Co-authored-by: shangxueink <138397030+shangxueink@users.noreply.github.com>
Co-authored-by: IGCrystal-A <244300990+IGCrystal-A@users.noreply.github.com>
Co-authored-by: RC-CHN <67079377+RC-CHN@users.noreply.github.com>
Co-authored-by: MC090610 <113341105+MC090610@users.noreply.github.com>
Co-authored-by: Waterwzy <196913419+Waterwzy@users.noreply.github.com>
Co-authored-by: Lanhuace-Wan <186303160+Lanhuace-Wan@users.noreply.github.com>
Co-authored-by: LiAlH4qwq <61769640+LiAlH4qwq@users.noreply.github.com>
Co-authored-by: HSOS6 <209910899+HSOS6@users.noreply.github.com>
Co-authored-by: th-dd <162813557+th-dd@users.noreply.github.com>
Co-authored-by: miaoxutao123 <81676466+miaoxutao123@users.noreply.github.com>
Co-authored-by: nuomicici <143102889+nuomicici@users.noreply.github.com>
Co-authored-by: nasyt233 <210103278+nasyt233@users.noreply.github.com>
Co-authored-by: jlugjb <7426462+jlugjb@users.noreply.github.com>
Co-authored-by: Raven95676 <176760093+Raven95676@users.noreply.github.com>
Co-authored-by: Futureppo <180109455+Futureppo@users.noreply.github.com>
Co-authored-by: MliKiowa <61873808+MliKiowa@users.noreply.github.com>
Co-authored-by: Fridemn <150212937+Fridemn@users.noreply.github.com>
Co-authored-by: BakaCookie520 <138355736+BakaCookie520@users.noreply.github.com>
Co-authored-by: YumeYuka <125112916+YumeYuka@users.noreply.github.com>
Co-authored-by: xming521 <32786500+xming521@users.noreply.github.com>
Co-authored-by: ywh555hhh <121592812+ywh555hhh@users.noreply.github.com>
Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com>
Co-authored-by: roeseth <41995115+roeseth@users.noreply.github.com>
Co-authored-by: ikun-1145141 <265925499+ikun-1145141@users.noreply.github.com>
Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com>
Co-authored-by: Yue-bin <60509781+Yue-bin@users.noreply.github.com>
Co-authored-by: W1ndys <109416673+W1ndys@users.noreply.github.com>
Co-authored-by: TheFurina <218887821+TheFurina@users.noreply.github.com>
Co-authored-by: Seayon <12275933+Seayon@users.noreply.github.com>
Co-authored-by: OnlyblackTea <38585636+OnlyblackTea@users.noreply.github.com>
Co-authored-by: ocetars <74854972+ocetars@users.noreply.github.com>
Co-authored-by: railgun19457 <117180744+railgun19457@users.noreply.github.com>
Co-authored-by: JunieXD <107397009+JunieXD@users.noreply.github.com>
Co-authored-by: advent259141 <197440256+advent259141@users.noreply.github.com>
Co-authored-by: Doge2077 <91442300+Doge2077@users.noreply.github.com>
Co-authored-by: Bocity <23430545+Bocity@users.noreply.github.com>
Co-authored-by: Aurora-xk <192227833+Aurora-xk@users.noreply.github.com>
2026-03-09 23:38:21 +08:00
ChuwuYo 795aec9578 feat(extension): add filtering and sorting for installed plugins in WebUI (#5923)
* feat(extension): add PluginSortControl reusable component for sorting

* i18n: add i18n keys for plugin sorting and filtering features

* feat(extension): add sorting and status filtering for installed plugins

Backend changes (plugin.py):
- Add _resolve_plugin_dir method to resolve plugin directory path
- Add _get_plugin_installed_at method to get installation time from file mtime
- Add installed_at field to plugin API response

Frontend changes (InstalledPluginsTab.vue):
- Import PluginSortControl component
- Add status filter toggle (all/enabled/disabled) using v-btn-toggle
- Integrate PluginSortControl for sorting options
- Add toolbar layout with actions and controls sections

Frontend changes (MarketPluginsTab.vue):
- Import PluginSortControl component
- Replace v-select + v-btn combination with unified PluginSortControl

Frontend changes (useExtensionPage.js):
- Add installedStatusFilter, installedSortBy, installedSortOrder refs
- Add installedSortItems and installedSortUsesOrder computed properties
- Add sortInstalledPlugins function with multi-criteria support
- Support sorting by install time, name, author, and update status
- Add status filtering in filteredPlugins computed property
- Disable default table sorting by setting sortable: false

* test: add tests for installed_at field in plugin API

- Assert all plugins have installed_at field in get_plugins response
- Assert installed_at is not null after plugin installation

* fix(extension): add explicit fallbacks for installed plugin sort comparisons

* i18n(extension): rename install time label to last modified

* fix(extension): cache installed_at parsing and validate timestamp format in tests

* test(dashboard): strengthen installed_at coverage for plugin API
2026-03-09 17:12:22 +09:00
Soulter 7d31140c14 chore: bump version to 4.19.4 2026-03-09 11:13:39 +08:00
Soulter 654112ca86 feat(wecomai): implement long connection mode and update configuration options (#5930) 2026-03-09 11:10:32 +08:00
273 changed files with 25335 additions and 525 deletions
+43
View File
@@ -0,0 +1,43 @@
name: release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@master
- name: nodejs installation
uses: actions/setup-node@v6
with:
node-version: "18"
- name: npm install
run: npm add -D vitepress
working-directory: './docs' # working-directory 指定 shell 命令运行目录
- name: npm run build
run: npm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
script: |
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /tmp/docs/
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
+68
View File
@@ -0,0 +1,68 @@
name: sync wiki
on:
workflow_dispatch:
push:
branches:
- master
paths:
- '.github/workflows/sync-wiki.yml'
- 'docs/scripts/sync_docs_to_wiki.py'
- 'docs/tests/test_sync_docs_to_wiki.py'
- 'docs/zh/**'
- 'docs/en/**'
concurrency:
group: sync-wiki-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate manual ref
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
run: |
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
exit 1
- name: Check out docs repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Run sync unit tests
working-directory: docs
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
- name: Validate internal doc links
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
- name: Clone AstrBot wiki
env:
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
run: |
test -n "$WIKI_TOKEN"
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
- name: Generate wiki pages
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
- name: Commit and push wiki changes
working-directory: wiki
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
if git diff --cached --quiet; then
echo "No wiki changes to push"
exit 0
fi
git commit -m "docs: sync wiki from AstrBot-1/docs"
git push
+2
View File
@@ -61,3 +61,5 @@ GenieData/
.codex/
.opencode/
.kilocode/
.worktrees/
docs/plans/
+2 -1
View File
@@ -234,7 +234,8 @@ pre-commit install
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
### Discord Server
+1
View File
@@ -222,6 +222,7 @@ pre-commit install
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811
### Serveur Discord
+1
View File
@@ -223,6 +223,7 @@ pre-commit install
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
- 開発者群(正式): 1039761811
### Discord サーバー
+1
View File
@@ -222,6 +222,7 @@ pre-commit install
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811
### Сервер Discord
+2 -1
View File
@@ -225,7 +225,8 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群:975206796
- 開發者群(闲聊吹水)975206796
- 開發者群(正式):1039761811
### Discord 群組
+2 -1
View File
@@ -226,7 +226,8 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群:975206796
- 开发者群(偏闲聊吹水)975206796
- 开发者群(正式):1039761811
### Discord 频道
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.19.3"
__version__ = "4.19.5"
+15 -1
View File
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.utils.pip_installer import (
DependencyConflictError as DependencyConflictError,
)
from astrbot.core.utils.pip_installer import (
PipInstaller,
)
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements as find_missing_requirements,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
)
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
+19 -6
View File
@@ -144,10 +144,14 @@ class MCPClient:
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(msg: str) -> None:
def logging_callback(
msg: str | mcp.types.LoggingMessageNotificationParams,
) -> None:
# Handle MCP service error logs
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
if "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
@@ -214,15 +218,24 @@ class MCPClient:
**cfg,
)
def callback(msg: str) -> None:
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
# Handle MCP service error logs
self.server_errlogs.append(msg)
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
if msg.level in (
"warning",
"error",
"critical",
"alert",
"emergency",
):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
+14 -1
View File
@@ -204,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": (
"Component type. One of: "
"plain, image, record, file, mention_user"
"plain, image, record, video, file, mention_user. Record is voice message."
),
},
"text": {
@@ -320,6 +320,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
components.append(Comp.Record.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for record component."
elif msg_type == "video":
path = msg.get("path")
url = msg.get("url")
if path:
(
local_path,
file_from_sandbox,
) = await self._resolve_path_from_sandbox(context, path)
components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Video.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for video component."
elif msg_type == "file":
path = msg.get("path")
url = msg.get("url")
+6
View File
@@ -422,6 +422,12 @@ async def get_booter(
) -> ComputerBooter:
config = context.get_config(umo=session_id)
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
if runtime == "local":
return get_local_booter()
elif runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+36 -14
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.19.3"
VERSION = "4.19.5"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
"telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
},
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
},
"wake_prefix": ["/"],
"log_level": "INFO",
@@ -342,19 +345,20 @@ CONFIG_METADATA_2 = {
"企业微信智能机器人": {
"id": "wecom_ai_bot",
"type": "wecom_ai_bot",
"hint": "如果发现字段有异常,请重新创建",
"enable": True,
"wecom_ai_bot_connection_mode": "webhook",
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
"wecom_ai_bot_name": "",
"wecomaibot_ws_bot_id": "",
"wecomaibot_ws_secret": "",
"wecomaibot_token": "",
"wecomaibot_encoding_aes_key": "",
"wecomaibot_init_respond_text": "",
"wecomaibot_friend_message_welcome_text": "",
"wecom_ai_bot_name": "",
"msg_push_webhook_url": "",
"only_use_webhook_url_to_send": False,
"long_connection_bot_id": "",
"long_connection_secret": "",
"long_connection_ws_url": "wss://openws.work.weixin.qq.com",
"long_connection_heartbeat_interval": 30,
"token": "",
"encoding_aes_key": "",
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
"wecomaibot_heartbeat_interval": 30,
"unified_webhook_mode": True,
"webhook_uuid": "",
"callback_server_host": "0.0.0.0",
@@ -754,6 +758,22 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
},
"wecomaibot_token": {
"description": "企业微信智能机器人 Token",
"type": "string",
"hint": "用于 Webhook 回调模式的身份验证。",
"condition": {
"wecom_ai_bot_connection_mode": "webhook",
},
},
"wecomaibot_encoding_aes_key": {
"description": "企业微信智能机器人 EncodingAESKey",
"type": "string",
"hint": "用于 Webhook 回调模式的消息加密解密。",
"condition": {
"wecom_ai_bot_connection_mode": "webhook",
},
},
"msg_push_webhook_url": {
"description": "企业微信消息推送 Webhook URL",
"type": "string",
@@ -764,7 +784,7 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
},
"long_connection_bot_id": {
"wecomaibot_ws_bot_id": {
"description": "长连接 BotID",
"type": "string",
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
@@ -772,7 +792,7 @@ CONFIG_METADATA_2 = {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"long_connection_secret": {
"wecomaibot_ws_secret": {
"description": "长连接 Secret",
"type": "string",
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
@@ -780,17 +800,19 @@ CONFIG_METADATA_2 = {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"long_connection_ws_url": {
"wecomaibot_ws_url": {
"description": "长连接 WebSocket 地址",
"type": "string",
"invisible": True,
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
},
},
"long_connection_heartbeat_interval": {
"wecomaibot_heartbeat_interval": {
"description": "长连接心跳间隔",
"type": "int",
"invisible": True,
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
"condition": {
"wecom_ai_bot_connection_mode": "long_connection",
@@ -840,7 +862,7 @@ CONFIG_METADATA_2 = {
"unified_webhook_mode": {
"description": "统一 Webhook 模式",
"type": "bool",
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}",
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}",
},
"webhook_uuid": {
"invisible": True,
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
@register_platform_adapter(
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
)
class LarkPlatformAdapter(Platform):
def __init__(
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
name="lark",
description="飞书机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_streaming_message=False,
support_streaming_message=True,
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import os
@@ -5,6 +6,14 @@ import uuid
from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.cardkit.v1 import (
ContentCardElementRequest,
ContentCardElementRequestBody,
CreateCardRequest,
CreateCardRequestBody,
SettingsCardRequest,
SettingsCardRequestBody,
)
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
@@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import (
convert_video_format,
get_media_duration,
)
from astrbot.core.utils.metrics import Metric
class LarkMessageEvent(AstrMessageEvent):
@@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent):
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
return
async def send_streaming(self, generator, use_fallback: bool = False):
async def _create_streaming_card(self) -> str | None:
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return None
card_json = {
"schema": "2.0",
"header": {
"title": {"content": "", "tag": "plain_text"},
},
"config": {
"streaming_mode": True,
"summary": {"content": ""},
"streaming_config": {
"print_frequency_ms": {"default": 50},
"print_step": {"default": 2},
"print_strategy": "fast",
},
},
"body": {
"elements": [
{
"tag": "markdown",
"content": "",
"element_id": "markdown_1",
}
]
},
}
request = (
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.acreate(request)
except Exception as e:
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
return None
if not response.success():
logger.error(
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
)
return None
if response.data is None or not response.data.card_id:
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
return None
card_id = response.data.card_id
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
return card_id
async def _send_card_message(
self,
card_id: str,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
"""将卡片实体作为 interactive 消息发送。"""
content = json.dumps(
{"type": "card", "data": {"card_id": card_id}},
ensure_ascii=False,
)
return await self._send_im_message(
self.bot,
content=content,
msg_type="interactive",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
async def _update_streaming_text(
self,
card_id: str,
content: str,
sequence: int,
) -> bool:
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return False
request = (
ContentCardElementRequest.builder()
.card_id(card_id)
.element_id("markdown_1")
.request_body(
ContentCardElementRequestBody.builder()
.content(content)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card_element.acontent(request)
except Exception as e:
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
return False
if not response.success():
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
return False
return True
async def _close_streaming_mode(
self,
card_id: str,
sequence: int,
) -> None:
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return
settings_json = json.dumps(
{"config": {"streaming_mode": False}},
ensure_ascii=False,
)
request = (
SettingsCardRequest.builder()
.card_id(card_id)
.request_body(
SettingsCardRequestBody.builder()
.settings(settings_json)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.asettings(request)
except Exception as e:
logger.error(f"[Lark] 关闭流式模式失败: {e}")
return
if not response.success():
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
else:
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
if buffer:
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
async def send_streaming(self, generator, use_fallback: bool = False):
"""使用 CardKit 流式卡片实现打字机效果。
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
发送频率由网络 RTT 自然限流。
"""
# Step 1: 创建流式卡片实体
card_id = await self._create_streaming_card()
if not card_id:
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
# Step 2: 发送卡片消息
sent = await self._send_card_message(
card_id,
reply_message_id=self.message_obj.message_id,
)
if not sent:
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
sequence = 0
delta = ""
last_sent = ""
done = False
text_changed = asyncio.Event()
async def _sender_loop() -> None:
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
nonlocal sequence, last_sent
while not done:
await text_changed.wait()
text_changed.clear()
snapshot = delta
if snapshot and snapshot != last_sent:
sequence += 1
ok = await self._update_streaming_text(card_id, snapshot, sequence)
if ok:
last_sent = snapshot
if delta != snapshot:
text_changed.set()
sender_task = asyncio.create_task(_sender_loop())
try:
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
if chain.type == "break":
# 飞书卡片不支持分段,忽略 break
continue
for comp in chain.chain:
if isinstance(comp, Plain):
delta += comp.text
text_changed.set()
finally:
done = True
text_changed.set()
await sender_task
# Step 4: 必要时补发最终文本 + 关闭流式模式
if delta and delta != last_sent:
sequence += 1
await self._update_streaming_text(card_id, delta, sequence)
sequence += 1
await self._close_streaming_mode(card_id, sequence)
# Step 5: 内联父类 send_streaming 的副作用
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
@@ -1,5 +1,5 @@
"""企业微信智能机器人平台适配器
基于企业微信智能机器人 API 的消息平台适配器支持 HTTP 回调
基于企业微信智能机器人 API 的消息平台适配器支持 HTTP 回调与长连接
参考webchat_adapter.py的队列机制实现异步消息处理和流式响应
"""
@@ -31,6 +31,7 @@ from .wecomai_api import (
WecomAIBotStreamMessageBuilder,
)
from .wecomai_event import WecomAIBotMessageEvent
from .wecomai_long_connection import WecomAIBotLongConnectionClient
from .wecomai_queue_mgr import WecomAIQueueMgr
from .wecomai_server import WecomAIBotServer
from .wecomai_utils import (
@@ -78,8 +79,13 @@ class WecomAIBotAdapter(Platform):
self.settings = platform_settings
# 初始化配置参数
self.token = self.config["token"]
self.encoding_aes_key = self.config["encoding_aes_key"]
self.connection_mode = self.config.get(
"wecom_ai_bot_connection_mode", "webhook"
)
self.token = self.config.get("token", self.config.get("wecomaibot_token", ""))
self.encoding_aes_key = self.config.get(
"encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "")
)
self.port = int(self.config["port"])
self.host = self.config.get("callback_server_host", "0.0.0.0")
self.bot_name = self.config.get("wecom_ai_bot_name", "")
@@ -96,25 +102,52 @@ class WecomAIBotAdapter(Platform):
self.only_use_webhook_url_to_send = bool(
self.config.get("only_use_webhook_url_to_send", False),
)
self.long_connection_bot_id = self.config.get(
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
)
self.long_connection_secret = self.config.get(
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
)
self.long_connection_ws_url = self.config.get(
"wecomaibot_ws_url",
"wss://openws.work.weixin.qq.com",
)
self.long_connection_heartbeat_interval = int(
self.config.get("wecomaibot_heartbeat_interval", 30),
)
# 平台元数据
self.metadata = PlatformMetadata(
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=bool(self.msg_push_webhook_url),
)
# 初始化 API 客户端
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
self.api_client: WecomAIBotAPIClient | None = None
self.server: WecomAIBotServer | None = None
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
# 初始化 HTTP 服务器
self.server = WecomAIBotServer(
host=self.host,
port=self.port,
api_client=self.api_client,
message_handler=self._process_message,
)
if self.connection_mode == "long_connection":
if not self.long_connection_bot_id or not self.long_connection_secret:
logger.warning(
"企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败"
)
self.long_connection_client = WecomAIBotLongConnectionClient(
bot_id=self.long_connection_bot_id,
secret=self.long_connection_secret,
ws_url=self.long_connection_ws_url,
heartbeat_interval=self.long_connection_heartbeat_interval,
message_handler=self._process_long_connection_payload,
)
else:
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
self.server = WecomAIBotServer(
host=self.host,
port=self.port,
api_client=self.api_client,
message_handler=self._process_message,
)
# 事件循环和关闭信号
self.shutdown_event = asyncio.Event()
@@ -161,6 +194,9 @@ class WecomAIBotAdapter(Platform):
加密后的响应消息无需响应时返回 None
"""
if not self.api_client:
logger.error("Webhook 消息处理失败: API 客户端未初始化")
return None
msgtype = message_data.get("msgtype")
if not msgtype:
logger.warning(f"消息类型未知,忽略: {message_data}")
@@ -320,6 +356,89 @@ class WecomAIBotAdapter(Platform):
logger.error("处理欢迎消息时发生异常: %s", e)
return None
async def _process_long_connection_payload(
self,
payload: dict[str, Any],
) -> None:
"""处理长连接回调消息。"""
cmd = payload.get("cmd")
headers = payload.get("headers") or {}
body = payload.get("body") or {}
req_id = headers.get("req_id")
if not isinstance(body, dict):
return
if cmd == "aibot_msg_callback":
session_id = self._extract_session_id(body)
stream_id = f"{session_id}_{generate_random_string(10)}"
await self._enqueue_message(
body, {"req_id": req_id or ""}, stream_id, session_id
)
self.queue_mgr.set_pending_response(
stream_id,
{
"req_id": req_id or "",
"connection_mode": "long_connection",
},
)
if self.initial_respond_text and req_id:
await self._send_long_connection_respond_msg(
req_id=req_id,
body={
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": False,
"content": self.initial_respond_text,
},
},
)
return
if cmd == "aibot_event_callback":
event = body.get("event") or {}
event_type = event.get("eventtype")
if (
event_type == "enter_chat"
and self.friend_message_welcome_text
and req_id
):
await self._send_long_connection_respond_welcome(req_id)
elif event_type == "disconnected_event":
logger.warning(
"[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭"
)
async def _send_long_connection_respond_welcome(self, req_id: str) -> bool:
client = self.long_connection_client
if not client:
return False
return await client.send_command(
cmd="aibot_respond_welcome_msg",
req_id=req_id,
body={
"msgtype": "text",
"text": {
"content": self.friend_message_welcome_text,
},
},
)
async def _send_long_connection_respond_msg(
self,
req_id: str,
body: dict[str, Any],
) -> bool:
client = self.long_connection_client
if not client:
return False
return await client.send_command(
cmd="aibot_respond_msg",
req_id=req_id,
body=body,
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
@@ -355,15 +474,16 @@ class WecomAIBotAdapter(Platform):
content = ""
image_base64 = []
_img_url_to_process = []
_img_url_to_process: list[tuple[str, str | None]] = []
msg_items = []
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
content = WecomAIBotMessageParser.parse_text_message(message_data)
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
_img_url_to_process.append(
WecomAIBotMessageParser.parse_image_message(message_data),
)
image_payload = message_data.get("image", {})
image_url = image_payload.get("url", "")
if image_url:
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
# 提取混合消息中的文本内容
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
@@ -374,9 +494,12 @@ class WecomAIBotAdapter(Platform):
if text_content:
text_parts.append(text_content)
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
image_url = item.get("image", {}).get("url", "")
image_payload = item.get("image", {})
image_url = image_payload.get("url", "")
if image_url:
_img_url_to_process.append(image_url)
_img_url_to_process.append(
(image_url, image_payload.get("aeskey"))
)
content = " ".join(text_parts) if text_parts else ""
else:
content = f"[{msgtype}消息]"
@@ -384,8 +507,8 @@ class WecomAIBotAdapter(Platform):
# 并行处理图片下载和解密
if _img_url_to_process:
tasks = [
process_encrypted_image(url, self.encoding_aes_key)
for url in _img_url_to_process
process_encrypted_image(url, aes_key or self.encoding_aes_key)
for url, aes_key in _img_url_to_process
]
results = await asyncio.gather(*tasks)
for success, result in results:
@@ -459,26 +582,43 @@ class WecomAIBotAdapter(Platform):
"""运行适配器,同时启动HTTP服务器和队列监听器"""
async def run_both() -> None:
# 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
# 只运行队列监听器
await self.queue_listener.run()
else:
if self.connection_mode == "long_connection":
if not self.long_connection_client:
raise RuntimeError("长连接客户端未初始化")
logger.info(
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
)
# 同时运行HTTP服务器和队列监听器
await asyncio.gather(
self.server.start_server(),
self.long_connection_client.start(),
self.queue_listener.run(),
)
else:
# 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
)
# 只运行队列监听器
await self.queue_listener.run()
else:
if not self.server:
raise RuntimeError("Webhook 服务器未初始化")
logger.info(
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
)
# 同时运行HTTP服务器和队列监听器
await asyncio.gather(
self.server.start_server(),
self.queue_listener.run(),
)
return run_both()
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
if self.connection_mode == "long_connection" or not self.server:
return "long_connection mode does not accept webhook callbacks", 400
# 根据请求方法分发到不同的处理函数
if request.method == "GET":
return await self.server.handle_verify(request)
@@ -489,7 +629,10 @@ class WecomAIBotAdapter(Platform):
"""终止适配器"""
logger.info("企业微信智能机器人适配器正在关闭...")
self.shutdown_event.set()
await self.server.shutdown()
if self.long_connection_client:
await self.long_connection_client.shutdown()
if self.server:
await self.server.shutdown()
def meta(self) -> PlatformMetadata:
"""获取平台元数据"""
@@ -507,17 +650,22 @@ class WecomAIBotAdapter(Platform):
queue_mgr=self.queue_mgr,
webhook_client=self.webhook_client,
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
long_connection_sender=self._send_long_connection_respond_msg,
)
message_event.is_at_or_wake_command = (
True # 企业微信智能机器人默认消息都是 at 或唤醒命令
)
message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
self.commit_event(message_event)
except Exception as e:
logger.error("处理消息时发生异常: %s", e)
def get_client(self) -> WecomAIBotAPIClient:
def get_client(self) -> WecomAIBotAPIClient | None:
"""获取 API 客户端"""
return self.api_client
def get_server(self) -> WecomAIBotServer:
def get_server(self) -> WecomAIBotServer | None:
"""获取 HTTP 服务器实例"""
return self.server
@@ -1,5 +1,7 @@
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
from collections.abc import Awaitable, Callable
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Image, Plain
@@ -18,10 +20,11 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
message_obj,
platform_meta,
session_id: str,
api_client: WecomAIBotAPIClient,
api_client: WecomAIBotAPIClient | None,
queue_mgr: WecomAIQueueMgr,
webhook_client: WecomAIBotWebhookClient | None = None,
only_use_webhook_url_to_send: bool = False,
long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
) -> None:
"""初始化消息事件
@@ -38,6 +41,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
self.queue_mgr = queue_mgr
self.webhook_client = webhook_client
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
self.long_connection_sender = long_connection_sender
async def _mark_stream_complete(self, stream_id: str) -> None:
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
@@ -117,6 +121,18 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
return data
@staticmethod
def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str:
if not message_chain:
return ""
plain_parts: list[str] = []
for comp in message_chain.chain:
if isinstance(comp, At):
plain_parts.append(f"@{comp.name} ")
elif isinstance(comp, Plain):
plain_parts.append(comp.text)
return "".join(plain_parts).strip()
async def send(self, message: MessageChain | None) -> None:
"""发送消息"""
raw = self.message_obj.raw_message
@@ -124,6 +140,44 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
"connection_mode"
)
req_id = pending_response.get("callback_params", {}).get("req_id")
if (
connection_mode == "long_connection"
and self.long_connection_sender
and isinstance(req_id, str)
and req_id
):
if self.only_use_webhook_url_to_send and self.webhook_client and message:
await self.webhook_client.send_message_chain(message)
await super().send(MessageChain([]))
return
if self.webhook_client and message:
await self.webhook_client.send_message_chain(
message,
unsupported_only=True,
)
content = self._extract_plain_text_from_chain(message)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": content,
},
},
)
await super().send(MessageChain([]))
return
if self.only_use_webhook_url_to_send and self.webhook_client and message:
await self.webhook_client.send_message_chain(message)
await self._mark_stream_complete(stream_id)
@@ -152,8 +206,77 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
"connection_mode"
)
req_id = pending_response.get("callback_params", {}).get("req_id")
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
if (
connection_mode == "long_connection"
and self.long_connection_sender
and isinstance(req_id, str)
and req_id
):
if self.only_use_webhook_url_to_send and self.webhook_client:
merged_chain = MessageChain([])
async for chain in generator:
merged_chain.chain.extend(chain.chain)
merged_chain.squash_plain()
await self.webhook_client.send_message_chain(merged_chain)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": "",
},
},
)
await super().send_streaming(generator, use_fallback)
return
increment_plain = ""
async for chain in generator:
if self.webhook_client:
await self.webhook_client.send_message_chain(
chain,
unsupported_only=True,
)
chain.squash_plain()
chunk_text = self._extract_plain_text_from_chain(chain)
if chunk_text:
increment_plain += chunk_text
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": False,
"content": increment_plain,
},
},
)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": increment_plain,
},
},
)
await super().send_streaming(generator, use_fallback)
return
if self.only_use_webhook_url_to_send and self.webhook_client:
merged_chain = MessageChain([])
async for chain in generator:
@@ -0,0 +1,236 @@
"""企业微信智能机器人长连接客户端。"""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
import aiohttp
from astrbot.api import logger
class WecomAIBotLongConnectionClient:
"""企业微信智能机器人 WebSocket 长连接客户端。"""
def __init__(
self,
bot_id: str,
secret: str,
ws_url: str,
heartbeat_interval: int,
message_handler: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
self.bot_id = bot_id
self.secret = secret
self.ws_url = ws_url
self.heartbeat_interval = max(5, int(heartbeat_interval))
self.message_handler = message_handler
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._shutdown_event = asyncio.Event()
self._send_lock = asyncio.Lock()
self._command_lock = asyncio.Lock()
self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
@staticmethod
def gen_req_id() -> str:
return uuid.uuid4().hex
async def start(self) -> None:
"""启动长连接并自动重连。"""
reconnect_delay = 1
while not self._shutdown_event.is_set():
try:
await self._run_once()
reconnect_delay = 1
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("[WecomAI][LongConn] 长连接异常: %s", e)
if self._shutdown_event.is_set():
break
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 30)
async def _run_once(self) -> None:
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None)
async with aiohttp.ClientSession(timeout=timeout) as session:
self._session = session
logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url)
async with session.ws_connect(
self.ws_url, heartbeat=None, autoping=True
) as ws:
self._ws = ws
await self._subscribe()
logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接")
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
while not self._shutdown_event.is_set():
message = await ws.receive()
if message.type == aiohttp.WSMsgType.TEXT:
await self._handle_text_message(message.data)
elif message.type in {
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR,
}:
break
finally:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
self._ws = None
async def _subscribe(self) -> None:
"""发送 aibot_subscribe,并等待响应。"""
req_id = self.gen_req_id()
payload = {
"cmd": "aibot_subscribe",
"headers": {"req_id": req_id},
"body": {"bot_id": self.bot_id, "secret": self.secret},
}
await self._send_json(payload)
if not self._ws:
raise RuntimeError("WebSocket 未建立")
reply = await self._ws.receive(timeout=10)
if reply.type != aiohttp.WSMsgType.TEXT:
raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}")
data = json.loads(reply.data)
if data.get("errcode") != 0:
raise RuntimeError(
f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}"
)
async def _heartbeat_loop(self) -> None:
while not self._shutdown_event.is_set():
await asyncio.sleep(self.heartbeat_interval)
if self._shutdown_event.is_set():
break
try:
await self.send_command("ping", self.gen_req_id(), None)
except Exception as e:
logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e)
return
async def _handle_text_message(self, text: str) -> None:
try:
payload = json.loads(text)
except json.JSONDecodeError:
logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text)
return
headers = payload.get("headers") or {}
req_id = headers.get("req_id")
if isinstance(req_id, str):
waiter = self._response_waiters.get(req_id)
if waiter and not waiter.done():
waiter.set_result(payload)
return
cmd = payload.get("cmd")
if cmd in {"aibot_msg_callback", "aibot_event_callback"}:
await self.message_handler(payload)
return
if payload.get("errcode") not in (None, 0):
logger.warning(
"[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s",
payload.get("errcode"),
payload.get("errmsg"),
)
async def send_command(
self,
cmd: str,
req_id: str,
body: dict[str, Any] | None,
) -> bool:
"""发送长连接命令。"""
headers = {"req_id": req_id}
payload: dict[str, Any] = {"cmd": cmd, "headers": headers}
if body is not None:
payload["body"] = body
async with self._command_lock:
max_retries = 3
for attempt in range(max_retries + 1):
response = await self._send_and_wait_response(req_id, payload)
if not response:
if attempt < max_retries:
await asyncio.sleep(min(0.2 * (2**attempt), 2.0))
continue
return False
errcode = response.get("errcode")
if errcode in (0, None):
return True
if errcode == 6000 and attempt < max_retries:
backoff = min(0.2 * (2**attempt), 2.0)
logger.warning(
"[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d",
cmd,
req_id,
attempt + 1,
)
await asyncio.sleep(backoff)
continue
logger.warning(
"[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s",
cmd,
req_id,
errcode,
response.get("errmsg"),
)
return False
return False
async def _send_and_wait_response(
self,
req_id: str,
payload: dict[str, Any],
timeout: float = 10.0,
) -> dict[str, Any] | None:
loop = asyncio.get_running_loop()
waiter: asyncio.Future[dict[str, Any]] = loop.create_future()
self._response_waiters[req_id] = waiter
try:
await self._send_json(payload)
return await asyncio.wait_for(waiter, timeout=timeout)
except TimeoutError:
logger.warning(
"[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s",
payload.get("cmd"),
req_id,
)
return None
finally:
self._response_waiters.pop(req_id, None)
async def _send_json(self, payload: dict[str, Any]) -> None:
ws = self._ws
if ws is None or ws.closed:
raise RuntimeError("长连接尚未建立")
async with self._send_lock:
await ws.send_json(payload)
async def shutdown(self) -> None:
self._shutdown_event.set()
ws = self._ws
if ws is not None and not ws.closed:
await ws.close()
session = self._session
if session is not None and not session.closed:
await session.close()
+23 -17
View File
@@ -21,8 +21,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
MAX_MCP_TIMEOUT_SECONDS = 300.0
@@ -417,9 +417,11 @@ class FunctionToolManager:
for (name, cfg, _), result in zip(active_configs, results, strict=False):
if isinstance(result, Exception):
if isinstance(result, MCPInitTimeoutError):
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
logger.error(
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
)
else:
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
logger.error(f"Failed to initialize MCP server {name}: {result}")
self._log_safe_mcp_debug_config(cfg)
failed_services.append(name)
async with self._runtime_lock:
@@ -430,16 +432,18 @@ class FunctionToolManager:
if failed_services:
logger.warning(
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}"
f"请检查配置文件 mcp_server.json 和服务器可用性。"
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
f"Please check the mcp_server.json file and server availability."
)
summary = MCPInitSummary(
total=len(active_configs), success=success_count, failed=failed_services
)
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
logger.info(
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
)
if summary.total > 0 and summary.success == 0:
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
if raise_on_all_failed:
raise MCPAllServicesFailedError(msg)
logger.error(msg)
@@ -461,7 +465,7 @@ class FunctionToolManager:
async with self._runtime_lock:
if name in self._mcp_server_runtime or name in self._mcp_starting:
logger.warning(
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
)
self._log_safe_mcp_debug_config(cfg)
return
@@ -478,10 +482,10 @@ class FunctionToolManager:
)
except asyncio.TimeoutError as exc:
raise MCPInitTimeoutError(
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from exc
except Exception:
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
raise
finally:
if mcp_client is None:
@@ -491,9 +495,9 @@ class FunctionToolManager:
async def lifecycle() -> None:
try:
await shutdown_event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
logger.info(f"Received shutdown signal for MCP client {name}")
except asyncio.CancelledError:
logger.debug(f"MCP 客户端 {name} 任务被取消")
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
await self._terminate_mcp_client(name)
@@ -545,7 +549,7 @@ class FunctionToolManager:
if strict:
raise MCPShutdownTimeoutError(pending_names, timeout)
logger.warning(
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
f"{timeout:g}",
", ".join(pending_names),
)
@@ -568,7 +572,9 @@ class FunctionToolManager:
try:
await mcp_client.cleanup()
except Exception as cleanup_exc: # noqa: BLE001 - only log here
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
logger.error(
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
)
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
"""初始化单个MCP客户端"""
@@ -602,7 +608,7 @@ class FunctionToolManager:
)
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
return mcp_client
async def _terminate_mcp_client(self, name: str) -> None:
@@ -622,7 +628,7 @@ class FunctionToolManager:
async with self._runtime_lock:
self._mcp_server_runtime.pop(name, None)
self._mcp_starting.discard(name)
logger.info(f"已关闭 MCP 服务 {name}")
logger.info(f"Disconnected from MCP server {name}")
return
# Runtime missing but stale tools may still exist after failed flows.
+18 -18
View File
@@ -79,6 +79,7 @@ class ProviderManager:
self._provider_change_hooks: list[
Callable[[str, ProviderType, str | None], None]
] = []
self._mcp_init_task: asyncio.Task | None = None
def set_provider_change_callback(
self,
@@ -330,24 +331,16 @@ class ProviderManager:
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
# 初始化 MCP Client 连接(等待完成以确保工具可用)
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
mcp_init_summary = await self.llm_tools.init_mcp_clients(
raise_on_all_failed=strict_mcp_init
)
if (
mcp_init_summary.total > 0
and mcp_init_summary.success == 0
and not strict_mcp_init
):
logger.warning(
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
async def _init_mcp_clients_bg() -> None:
try:
await self.llm_tools.init_mcp_clients()
except Exception:
logger.error("MCP init background task failed", exc_info=True)
if self._mcp_init_task is None or self._mcp_init_task.done():
self._mcp_init_task = asyncio.create_task(
_init_mcp_clients_bg(),
name="provider-manager:mcp-init",
)
def dynamic_import_provider(self, type: str) -> None:
@@ -817,6 +810,13 @@ class ProviderManager:
await self.load_provider(new_config)
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
self._mcp_init_task.cancel()
try:
await self._mcp_init_task
except asyncio.CancelledError:
pass
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate() # type: ignore
+18 -1
View File
@@ -281,7 +281,24 @@ class TTSProvider(AbstractProvider):
accumulated_text += text_part
async def test(self) -> None:
await self.get_audio("hi")
audio_path = await self.get_audio("hi")
# 检查生成的音频文件是否有效
if not os.path.exists(audio_path):
raise Exception("TTS test failed: audio file was not created")
file_size = os.path.getsize(audio_path)
if file_size == 0:
raise Exception(
"TTS test failed: generated audio file is empty (0 bytes). "
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
)
# 清理测试文件
try:
os.remove(audio_path)
except Exception:
pass
class EmbeddingProvider(AbstractProvider):
@@ -20,6 +20,7 @@ from ..register import register_provider_adapter
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
class OTTSProvider:
@@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider):
"azure_tts_subscription_key",
"",
).strip()
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = (
@@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider):
raise ValueError(error_msg) from e
except KeyError as e:
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
return AzureNativeProvider(config, self.provider_settings)
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
async def get_audio(self, text: str) -> str:
if isinstance(self.provider, OTTSProvider):
@@ -154,6 +154,14 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
audio_stream = self._call_tts_stream(text)
audio = await self._audio_play(audio_stream)
# 检查音频数据是否为空
if not audio or len(audio) == 0:
raise Exception(
"MiniMax TTS API returned empty audio data. "
"Please verify your configuration, especially the 'group_id' parameter. "
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
)
# 结果保存至文件
with open(path, "wb") as file:
file.write(audio)
@@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
return path
except aiohttp.ClientError as e:
raise e
raise Exception(f"MiniMax TTS API request failed: {e!s}")
+97 -9
View File
@@ -14,7 +14,12 @@ import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import logger, pip_installer, sp
from astrbot.core import (
DependencyConflictError,
logger,
pip_installer,
sp,
)
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
@@ -27,6 +32,10 @@ from astrbot.core.utils.astrbot_path import (
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
)
from . import StarMetadata
from .command_management import sync_command_configs
@@ -48,6 +57,49 @@ class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
def __init__(
self,
*,
plugin_label: str,
requirements_path: str,
error: Exception,
) -> None:
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
super().__init__(message)
self.plugin_label = plugin_label
self.requirements_path = requirements_path
self.error = error
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
try:
missing = find_missing_requirements_or_raise(requirements_path)
except RequirementsPrecheckFailed:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not missing:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(missing)}"
)
await pip_installer.install(requirements_path=requirements_path)
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
@@ -198,15 +250,37 @@ class PluginManager:
to_update.append(p.root_dir_name)
for p in to_update:
plugin_path = os.path.join(plugin_dir, p)
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
pth = os.path.join(plugin_path, "requirements.txt")
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
try:
await pip_installer.install(requirements_path=pth)
except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
await self._ensure_plugin_requirements(plugin_path, p)
return True
async def _ensure_plugin_requirements(
self,
plugin_dir_path: str,
plugin_label: str,
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not os.path.exists(requirements_path):
return
try:
await _install_requirements_with_precheck(
plugin_label=plugin_label,
requirements_path=requirements_path,
)
except asyncio.CancelledError:
raise
except DependencyConflictError as e:
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
raise
except Exception as e:
dependency_error = PluginDependencyInstallError(
plugin_label=plugin_label,
requirements_path=requirements_path,
error=e,
)
logger.exception(str(dependency_error))
raise dependency_error from e
async def _import_plugin_with_dependency_recovery(
self,
path: str,
@@ -422,7 +496,7 @@ class PluginManager:
root_dir_name: str,
plugin_dir_path: str,
reserved: bool,
error: Exception | str,
error: BaseException | str,
error_trace: str,
) -> dict:
record: dict = {
@@ -495,6 +569,9 @@ class PluginManager:
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
@@ -1078,6 +1155,10 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
@@ -1317,6 +1398,12 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
await self._ensure_plugin_requirements(
plugin_dir_path,
plugin_name,
)
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str) -> None:
@@ -1488,6 +1575,7 @@ class PluginManager:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
await self._ensure_plugin_requirements(desti_dir, dir_name)
# await self.reload()
success, error_message = await self.load(
specified_dir_name=dir_name,
+121
View File
@@ -0,0 +1,121 @@
import contextlib
import functools
import importlib.metadata as importlib_metadata
import logging
import os
from collections.abc import Iterator
from packaging.requirements import Requirement
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name,
collect_installed_distribution_versions,
get_requirement_check_paths,
)
logger = logging.getLogger("astrbot")
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
if core_dist_name:
try:
importlib_metadata.distribution(core_dist_name)
return core_dist_name
except importlib_metadata.PackageNotFoundError:
return None
try:
importlib_metadata.distribution("AstrBot")
return "AstrBot"
except importlib_metadata.PackageNotFoundError:
pass
if not __package__:
return None
top_pkg = __package__.split(".")[0]
for dist in importlib_metadata.distributions():
try:
top_level = dist.read_text("top_level.txt") or ""
except Exception:
continue
if top_pkg in top_level.splitlines():
if "Name" in dist.metadata:
return dist.metadata["Name"]
return None
@functools.cache
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
try:
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
except Exception as exc:
logger.warning("解析核心分发名称失败: %s", exc)
return ()
if not resolved_core_dist_name:
return ()
try:
dist = importlib_metadata.distribution(resolved_core_dist_name)
except importlib_metadata.PackageNotFoundError:
return ()
except Exception as exc:
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
return ()
if not dist or not dist.requires:
return ()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if not installed:
return ()
constraints: list[str] = []
for req_str in dist.requires:
try:
req = Requirement(req_str)
if req.marker and not req.marker.evaluate():
continue
name = canonicalize_distribution_name(req.name)
if name in installed:
constraints.append(f"{name}=={installed[name]}")
except Exception:
continue
return tuple(constraints)
class CoreConstraintsProvider:
def __init__(self, core_dist_name: str | None) -> None:
self._core_dist_name = core_dist_name
@contextlib.contextmanager
def constraints_file(self) -> Iterator[str | None]:
constraints = _get_core_constraints(self._core_dist_name)
if not constraints:
yield None
return
path: str | None = None
try:
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
) as f:
f.write("\n".join(constraints))
path = f.name
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
except Exception as exc:
logger.warning("创建临时约束文件失败: %s", exc)
yield None
return
try:
yield path
finally:
if path and os.path.exists(path):
with contextlib.suppress(Exception):
os.remove(path)
+428 -96
View File
@@ -7,21 +7,71 @@ import io
import logging
import os
import re
import shlex
import sys
import threading
from collections import deque
from dataclasses import dataclass
from urllib.parse import urlparse
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name as _canonicalize_distribution_name,
)
from astrbot.core.utils.requirements_utils import (
extract_requirement_name,
extract_requirement_names,
parse_package_install_input,
)
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
_PIP_FAILURE_PATTERNS = {
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
}
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
)
_MAX_PIP_OUTPUT_LINES = 200
def _canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
class DependencyConflictError(Exception):
"""Raised when pip encounters a dependency conflict."""
def __init__(
self, message: str, errors: list[str], *, is_core_conflict: bool
) -> None:
super().__init__(message)
self.errors = errors
self.is_core_conflict = is_core_conflict
class PipInstallError(Exception):
"""Raised when pip install fails without a classified dependency conflict."""
def __init__(self, message: str, *, code: int) -> None:
super().__init__(message)
self.code = code
@dataclass
class PipConflictContext:
relevant_lines: list[str]
requested_lines: list[str]
dependency_detail_lines: list[str]
constraint_lines: list[str]
has_strong_conflict_signal: bool
has_contextual_conflict_signal: bool
def _get_pip_main():
@@ -41,11 +91,12 @@ def _get_pip_main():
return pip_main
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
stream = io.StringIO()
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
result_code = pip_main(args)
return result_code, stream.getvalue()
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
sys.path.insert(0, normalized_target)
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
@@ -59,76 +110,258 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
handler.close()
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
sys.path.insert(0, normalized_target)
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
host = parsed.hostname
if host == "mirrors.aliyun.com":
return host
return None
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _normalize_sensitive_pip_key(raw_key: str) -> str:
return raw_key.lstrip("-").replace("-", "_").lower()
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
def _redact_url_credentials(raw_value: str) -> str:
"""Redact URL credentials and known inline secret values for safe logging."""
parsed = urlparse(raw_value)
if parsed.netloc and "@" in parsed.netloc:
hostname = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
if raw_value.startswith("--"):
option, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(option):
return f"{option}=****"
return raw_value
key, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(key):
return f"{key}=****"
return raw_value
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
redacted_args: list[str] = []
redact_next_value = False
for arg in args:
if redact_next_value:
redacted_args.append("****")
redact_next_value = False
continue
if arg.startswith("--") and "=" in arg:
option, value = arg.split("=", 1)
if _is_sensitive_pip_value_key(option):
redacted_args.append(f"{option}=****")
else:
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
continue
if arg.startswith("-i") and arg != "-i":
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
continue
if _is_sensitive_pip_value_key(arg):
redacted_args.append(arg)
redact_next_value = True
continue
redacted_args.append(_redact_url_credentials(arg))
return redacted_args
def _package_specs_override_index(package_specs: list[str]) -> bool:
for index, spec in enumerate(package_specs):
if spec == "--no-index":
return True
if spec in {"-i", "--index-url"}:
if index + 1 < len(package_specs):
return True
continue
if spec.startswith("--index-url="):
return True
if spec.startswith("-i") and spec != "-i":
return True
return False
class _StreamingLogWriter(io.TextIOBase):
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
self._log_func = log_func
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
self._buffer = ""
def write(self, text: str) -> int:
if not text:
return 0
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
while "\n" in self._buffer:
raw_line, self._buffer = self._buffer.split("\n", 1)
line = raw_line.rstrip("\r\n")
self._log_func(line)
self._lines.append(line)
return len(text)
def flush(self) -> None:
line = self._buffer.rstrip("\r\n")
if line:
self._log_func(line)
self._lines.append(line)
self._buffer = ""
@property
def lines(self) -> list[str]:
return list(self._lines)
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
with (
contextlib.redirect_stdout(stream),
contextlib.redirect_stderr(stream),
):
result_code = pip_main(args)
stream.flush()
return result_code, stream.lines
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
def _normalize_conflict_detail_line(line: str) -> str:
stripped = line.strip()
if _matches_pip_failure_pattern(stripped, "user_requested"):
return re.sub(
r"^\s*The user requested\s+",
"",
stripped,
flags=re.IGNORECASE,
)
except ValueError:
return False
return stripped
def _extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
if line.startswith("-"):
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
matched_indices = [
index
for index, line in enumerate(output_lines)
if _matches_pip_failure_pattern(line)
]
if matched_indices:
relevant_index_set: set[int] = set()
for index in matched_indices:
start = max(0, index - 1)
end = min(len(output_lines), index + 2)
relevant_index_set.update(range(start, end))
relevant_output_lines = [
line
for index, line in enumerate(output_lines)
if index in relevant_index_set
]
else:
relevant_output_lines = output_lines[-5:]
if not relevant_output_lines:
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return _canonicalize_distribution_name(egg_match.group(1))
dependency_detail_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "dependency_detail")
]
requested_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "user_requested")
and not _matches_pip_failure_pattern(line, "constraint")
]
if not requested_lines:
requested_lines = [
line
for line in dependency_detail_lines
if not _matches_pip_failure_pattern(line, "constraint")
]
constraint_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "constraint")
]
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
has_strong_conflict_signal = any(
_matches_pip_failure_pattern(
line,
"resolution_impossible",
"cannot_install",
)
for line in relevant_output_lines
)
has_contextual_conflict_signal = any(
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
return PipConflictContext(
relevant_lines=relevant_output_lines,
requested_lines=requested_lines,
dependency_detail_lines=dependency_detail_lines,
constraint_lines=constraint_lines,
has_strong_conflict_signal=has_strong_conflict_signal,
has_contextual_conflict_signal=has_contextual_conflict_signal,
)
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
context = _build_pip_conflict_context(output_lines)
if context is None:
return None
return _canonicalize_distribution_name(candidate)
if (
not context.has_strong_conflict_signal
and not context.has_contextual_conflict_signal
and not (context.requested_lines and context.constraint_lines)
):
return None
def _extract_requirement_names(requirements_path: str) -> set[str]:
names: set[str] = set()
try:
with open(requirements_path, encoding="utf-8") as requirements_file:
for line in requirements_file:
requirement_name = _extract_requirement_name(line)
if requirement_name:
names.add(requirement_name)
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return names
is_core_conflict = bool(context.constraint_lines)
detail = ""
if context.constraint_lines and context.requested_lines:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}"
)
elif len(context.dependency_detail_lines) >= 2:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}"
)
if is_core_conflict:
message = (
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
)
else:
message = f"检测到依赖冲突。{detail}"
return DependencyConflictError(
message,
context.relevant_lines,
is_core_conflict=is_core_conflict,
)
def _extract_top_level_modules(
@@ -155,7 +388,11 @@ def _collect_candidate_modules(
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
try:
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
distribution_name = distribution.metadata.get("Name")
distribution_name = (
distribution.metadata["Name"]
if "Name" in distribution.metadata
else None
)
if not distribution_name:
continue
canonical_name = _canonicalize_distribution_name(distribution_name)
@@ -173,7 +410,7 @@ def _collect_candidate_modules(
for distribution in by_name.get(requirement_name, []):
for dependency_line in distribution.requires or []:
dependency_name = _extract_requirement_name(dependency_line)
dependency_name = extract_requirement_name(dependency_line)
if not dependency_name:
continue
if dependency_name in expanded_requirement_names:
@@ -230,6 +467,38 @@ def _ensure_preferred_modules(
raise RuntimeError(conflict_message)
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
)
except ValueError:
return False
def _prefer_module_from_site_packages(
module_name: str, site_packages_path: str
) -> bool:
@@ -531,9 +800,63 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
class PipInstaller:
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
def __init__(
self,
pip_install_arg: str,
pypi_index_url: str | None = None,
core_dist_name: str | None = "AstrBot",
) -> None:
self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url
self.core_dist_name = core_dist_name
self._core_constraints = CoreConstraintsProvider(core_dist_name)
def _build_pip_args(
self,
package_name: str | None,
requirements_path: str | None,
mirror: str | None,
) -> tuple[list[str], set[str]]:
args: list[str] = []
requested_requirements: set[str] = set()
normalized_requirements_path = (
requirements_path.strip() if requirements_path else ""
)
if package_name and normalized_requirements_path:
raise ValueError(
"package_name and requirements_path cannot be used together"
)
if package_name:
parsed_package = parse_package_install_input(package_name)
if parsed_package.specs:
args = ["install", *parsed_package.specs]
requested_requirements = set(parsed_package.requirement_names)
elif normalized_requirements_path:
args = ["install", "-r", normalized_requirements_path]
requested_requirements = extract_requirement_names(
normalized_requirements_path
)
if not args:
return [], requested_requirements
pip_install_args = (
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
)
if not _package_specs_override_index([*args[1:], *pip_install_args]):
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
trusted_host = _get_trusted_host_for_index_url(index_url)
if trusted_host:
args.extend(["--trusted-host", trusted_host])
args.extend(["-i", index_url])
if pip_install_args:
args.extend(pip_install_args)
return args, requested_requirements
async def install(
self,
@@ -541,36 +864,37 @@ class PipInstaller:
requirements_path: str | None = None,
mirror: str | None = None,
) -> None:
args = ["install"]
requested_requirements: set[str] = set()
if package_name:
args.append(package_name)
requirement_name = _extract_requirement_name(package_name)
if requirement_name:
requested_requirements.add(requirement_name)
elif requirements_path:
args.extend(["-r", requirements_path])
requested_requirements = _extract_requirement_names(requirements_path)
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
args, requested_requirements = self._build_pip_args(
package_name, requirements_path, mirror
)
if not args:
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
return
target_site_packages = None
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
args.extend(["--target", target_site_packages])
args.extend(["--upgrade", "--force-reinstall"])
args.extend(
[
"--target",
target_site_packages,
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
with self._core_constraints.constraints_file() as constraints_file_path:
if constraints_file_path:
args.extend(["-c", constraints_file_path])
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
logger.info(
"Pip 包管理器 argv: %s",
["pip", *_redact_pip_args_for_logging(args)],
)
await self._run_pip_with_classification(args)
if target_site_packages:
_prepend_sys_path(target_site_packages)
@@ -589,7 +913,7 @@ class PipInstaller:
if not os.path.isdir(target_site_packages):
return
requested_requirements = _extract_requirement_names(requirements_path)
requested_requirements = extract_requirement_names(requirements_path)
if not requested_requirements:
return
@@ -605,13 +929,21 @@ class PipInstaller:
_patch_distlib_finder_for_frozen_runtime()
original_handlers = list(logging.getLogger().handlers)
result_code, output = await asyncio.to_thread(
_run_pip_main_with_output, pip_main, args
)
for line in output.splitlines():
line = line.strip()
if line:
logger.info(line)
try:
result_code, output_lines = await asyncio.to_thread(
_run_pip_main_streaming, pip_main, args
)
finally:
_cleanup_added_root_handlers(original_handlers)
if result_code != 0:
conflict = _classify_pip_failure(output_lines)
if conflict:
raise conflict
_cleanup_added_root_handlers(original_handlers)
return result_code
async def _run_pip_with_classification(self, args: list[str]) -> None:
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
+408
View File
@@ -0,0 +1,408 @@
import importlib.metadata as importlib_metadata
import logging
import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
class RequirementsPrecheckFailed(Exception):
"""Raised when the pre-check of requirements fails."""
pass
@dataclass(frozen=True)
class ParsedPackageInput:
specs: tuple[str, ...]
requirement_names: frozenset[str]
def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
def strip_inline_requirement_comment(raw_input: str) -> str:
if raw_input.lstrip().startswith("#"):
return ""
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
try:
parsed_version = Version(version)
except InvalidVersion:
return False
return specifier.contains(parsed_version, prereleases=True)
def _looks_like_local_path_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return candidate in {".", ".."} or candidate.startswith(
("./", "../", "/", "~/", ".\\", "..\\", "\\")
)
def looks_like_direct_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return (
_looks_like_local_path_reference(candidate)
or candidate.startswith("git+")
or "://" in candidate
)
def extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return canonicalize_distribution_name(egg_match.group(1))
if line.startswith("-"):
return None
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
return None
return canonicalize_distribution_name(candidate)
def _parse_editable_or_direct_name(target: str) -> str | None:
name = extract_requirement_name(target)
if not name:
return None
if "#egg=" in target or not looks_like_direct_reference(target):
return name
return None
def _parse_requirement_name_and_spec(
line: str,
) -> tuple[str | None, SpecifierSet | None]:
if line.startswith(("-c", "--constraint")):
return None, None
try:
req = Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
return None, None
editable_target: str | None = None
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
editable_target = tokens[1]
elif tokens[0].startswith("--editable="):
editable_target = tokens[0].split("=", 1)[1]
if editable_target:
name = _parse_editable_or_direct_name(editable_target)
return (name, None) if name else (None, None)
name = _parse_editable_or_direct_name(line)
return (name, None) if name else (None, None)
if req.marker and not req.marker.evaluate():
return None, None
return canonicalize_distribution_name(req.name), (req.specifier or None)
def _parse_requirement_line(
line: str,
) -> tuple[str, SpecifierSet | None] | None:
name, specifier = _parse_requirement_name_and_spec(line)
return (name, specifier) if name else None
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
requirement_names: set[str] = set()
skip_next_for: str | None = None
for token in tokens:
if skip_next_for:
if skip_next_for == "editable":
name = _parse_editable_or_direct_name(token)
if name:
requirement_names.add(name)
skip_next_for = None
continue
if token in {"-e", "--editable"}:
skip_next_for = "editable"
continue
if token in {
"-i",
"--index-url",
"--extra-index-url",
"-f",
"--find-links",
"--trusted-host",
"-r",
"--requirement",
"-c",
"--constraint",
}:
skip_next_for = "option-value"
continue
if token.startswith(("--editable=",)):
editable_target = token.split("=", 1)[1]
name = _parse_editable_or_direct_name(editable_target)
if name:
requirement_names.add(name)
continue
if token.startswith(
(
"--index-url=",
"--extra-index-url=",
"--find-links=",
"--trusted-host=",
"--requirement=",
"--constraint=",
)
):
continue
if (
(token.startswith("-i") and token != "-i")
or (token.startswith("-f") and token != "-f")
or token == "--no-index"
):
continue
if token.startswith("-"):
continue
name, _ = _parse_requirement_name_and_spec(token)
if name:
requirement_names.add(name)
return frozenset(requirement_names)
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
specs: list[str] = []
requirement_names: set[str] = set()
normalized = raw_input.strip()
if not normalized:
return ParsedPackageInput(specs=(), requirement_names=frozenset())
for raw_line in normalized.splitlines():
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
try:
Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
continue
specs.extend(tokens)
requirement_names.update(
_extract_requirement_names_from_package_tokens(tokens)
)
continue
specs.append(line)
name, _ = _parse_requirement_name_and_spec(line)
if name:
requirement_names.add(name)
return ParsedPackageInput(
specs=tuple(specs),
requirement_names=frozenset(requirement_names),
)
def _iter_requirement_lines(
requirements_path: str,
_visited: set[str] | None = None,
) -> Iterator[str]:
visited = _visited or set()
resolved_path = os.path.realpath(requirements_path)
if resolved_path in visited:
logger.warning(
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
)
return
visited.add(resolved_path)
with open(resolved_path, encoding="utf-8") as f:
for raw_line in f:
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
tokens = shlex.split(line)
if not tokens:
continue
nested: str | None = None
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
nested = tokens[1]
elif tokens[0].startswith("--requirement="):
nested = tokens[0].split("=", 1)[1]
if nested:
if not os.path.isabs(nested):
nested = os.path.join(os.path.dirname(resolved_path), nested)
yield from _iter_requirement_lines(nested, _visited=visited)
continue
yield line
def iter_requirements(
requirements_path: str | None = None,
lines: Iterable[str] | None = None,
) -> Iterator[tuple[str, SpecifierSet | None]]:
if lines is None:
if requirements_path is None:
raise ValueError("Either requirements_path or lines must be provided")
lines = _iter_requirement_lines(requirements_path)
for line in lines:
parsed = _parse_requirement_line(line)
if parsed is not None:
yield parsed
def extract_requirement_names(requirements_path: str) -> set[str]:
try:
return {
name for name, _ in iter_requirements(requirements_path=requirements_path)
}
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return set()
def get_requirement_check_paths() -> list[str]:
paths = list(sys.path)
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
if os.path.isdir(target_site_packages):
paths.insert(0, target_site_packages)
return paths
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
distribution_name = (
distribution.metadata["Name"] if "Name" in distribution.metadata else None
)
if not distribution_name:
return None, None
return canonicalize_distribution_name(distribution_name), distribution.version
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
installed: dict[str, str] = {}
try:
for distribution in importlib_metadata.distributions(path=paths):
distribution_name, version = _canonical_distribution_identity(distribution)
if not distribution_name or not version:
continue
installed.setdefault(distribution_name, version)
except Exception as exc:
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
return None
return installed
def _load_requirement_lines_for_precheck(
requirements_path: str,
) -> tuple[bool, list[str] | None]:
try:
requirement_lines = list(_iter_requirement_lines(requirements_path))
except Exception as exc:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
requirements_path,
exc,
)
return False, None
fallback_line = next(
(
line
for line in requirement_lines
if (
(
line.startswith(("-e ", "--editable ", "--editable="))
and "#egg=" not in line
)
or (
_parse_requirement_line(line) is None
and looks_like_direct_reference(line)
)
)
),
None,
)
if fallback_line is not None:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
requirements_path,
fallback_line,
)
return False, None
return True, requirement_lines
def find_missing_requirements(requirements_path: str) -> set[str] | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if installed is None:
return None
missing: set[str] = set()
for name, specifier in required:
installed_version = installed.get(name)
if not installed_version:
missing.add(name)
continue
if specifier and not _specifier_contains_version(specifier, installed_version):
missing.add(name)
return missing
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path)
if missing is None:
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
return missing
+31 -1
View File
@@ -5,7 +5,8 @@ import os
import ssl
import traceback
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
import certifi
@@ -352,6 +353,34 @@ class PluginRoute(Route):
logger.warning(f"获取插件 Logo 失败: {e}")
return None
def _resolve_plugin_dir(self, plugin) -> Path | None:
if not plugin.root_dir_name:
return None
base_dir = Path(
self.plugin_manager.reserved_plugin_path
if plugin.reserved
else self.plugin_manager.plugin_store_path
)
plugin_dir = base_dir / plugin.root_dir_name
if not plugin_dir.is_dir():
return None
return plugin_dir
def _get_plugin_installed_at(self, plugin) -> str | None:
plugin_dir = self._resolve_plugin_dir(plugin)
if plugin_dir is None:
return None
try:
return datetime.fromtimestamp(
plugin_dir.stat().st_mtime,
timezone.utc,
).isoformat()
except OSError as exc:
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
return None
async def get_plugins(self):
_plugin_resp = []
plugin_name = request.args.get("name")
@@ -377,6 +406,7 @@ class PluginRoute(Route):
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": self._get_plugin_installed_at(plugin),
}
# 检查是否为全空的幽灵插件
if not any(
+9
View File
@@ -0,0 +1,9 @@
## What's Changed
### 新增
- 企业微信智能机器人支持长连接模式。[#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
### New
- Wecom AI Bot supports long-connection mode(Websockets). [#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
+43
View File
@@ -0,0 +1,43 @@
## What's Changed
### 新增
- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。
- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。
### 优化
- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
### 修复
- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。
- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。
- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。
### 文档
- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。
---
## What's Changed (EN)
### New Features
- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)).
- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)).
### Impprovement
- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)).
### Bug Fixes
- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)).
- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)).
- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)).
- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)).
### Documentation
- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)).
@@ -0,0 +1,97 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
label: {
type: String,
required: true,
},
order: {
type: String,
default: "desc",
},
ascendingLabel: {
type: String,
default: "Ascending",
},
descendingLabel: {
type: String,
default: "Descending",
},
showOrder: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "update:order"]);
const updateSortBy = (value) => {
emit("update:modelValue", value);
};
const toggleOrder = () => {
emit("update:order", props.order === "desc" ? "asc" : "desc");
};
</script>
<template>
<div class="plugin-sort-control">
<v-select
:model-value="modelValue"
:items="items"
density="compact"
variant="outlined"
hide-details
:label="label"
class="plugin-sort-control__select"
@update:model-value="updateSortBy"
>
<template #prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<v-btn
v-if="showOrder"
icon
variant="text"
density="compact"
@click="toggleOrder"
>
<v-icon>{{
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ order === "desc" ? descendingLabel : ascendingLabel }}
</v-tooltip>
</v-btn>
</div>
</template>
<style scoped>
.plugin-sort-control {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plugin-sort-control__select {
min-width: 180px;
max-width: 220px;
}
.plugin-sort-control__select :deep(.v-field__input),
.plugin-sort-control__select :deep(.v-field-label),
.plugin-sort-control__select :deep(.v-select__selection-text),
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
font-size: 0.875rem;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
const { tm } = useModuleI18n('features/command');
@@ -52,6 +53,7 @@ const statusItems = [
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
@@ -108,10 +110,11 @@ const statusItems = [
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', $event)"
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -3,6 +3,7 @@
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
import { normalizeTextInput } from '@/utils/inputValue';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
*
*/
const filteredCommands = computed(() => {
const query = searchQuery.value.toLowerCase();
const query = normalizeTextInput(searchQuery.value).toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
isGroupExpanded
};
}
@@ -15,6 +15,7 @@
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
// Composables
import { useComponentData } from './composables/useComponentData';
@@ -83,7 +84,7 @@ const {
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = toolSearch.value.trim().toLowerCase();
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
v-model="toolSearch"
:model-value="toolSearch"
@update:model-value="toolSearch = normalizeTextInput($event)"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
@@ -7,6 +7,7 @@
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
@@ -161,6 +162,7 @@
<script setup>
import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
const props = defineProps({
entries: {
@@ -222,7 +224,7 @@ const emit = defineEmits([
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', val)
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
import { normalizeTextInput } from '@/utils/inputValue'
export interface UseProviderSourcesOptions {
defaultTab?: string
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
})
const filteredMergedModelEntries = computed(() => {
const term = modelSearch.value.trim().toLowerCase()
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => {
@@ -550,6 +550,10 @@
"description": "WeCom AI Bot Name",
"hint": "Must be correct; otherwise some commands won't work."
},
"wecom_ai_bot_connection_mode": {
"description": "WeCom AI Bot Connection Mode",
"hint": "Webhook mode requires Token/EncodingAESKey; long_connection mode requires BotID/Secret."
},
"wecomaibot_friend_message_welcome_text": {
"description": "WeCom AI Bot DM Welcome Message",
"hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable."
@@ -558,6 +562,30 @@
"description": "WeCom AI Bot Initial Response Text",
"hint": "First reply when the bot receives a message. Leave empty to disable."
},
"wecomaibot_token": {
"description": "WeCom AI Bot Token",
"hint": "Used for authentication in webhook callback mode."
},
"wecomaibot_encoding_aes_key": {
"description": "WeCom AI Bot EncodingAESKey",
"hint": "Used for message encryption/decryption in webhook callback mode."
},
"wecomaibot_ws_bot_id": {
"description": "Long Connection BotID",
"hint": "BotID credential for WeCom AI Bot long connection mode."
},
"wecomaibot_ws_secret": {
"description": "Long Connection Secret",
"hint": "Secret credential for WeCom AI Bot long connection mode."
},
"wecomaibot_ws_url": {
"description": "Long Connection WebSocket URL",
"hint": "Default is wss://openws.work.weixin.qq.com and usually does not need changes."
},
"wecomaibot_heartbeat_interval": {
"description": "Long Connection Heartbeat Interval",
"hint": "Heartbeat interval (seconds) in long connection mode. 30 seconds is recommended."
},
"wpp_active_message_poll": {
"description": "Enable Proactive Message Polling",
"hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default."
@@ -845,7 +873,8 @@
]
},
"regex": {
"description": "Segmentation Regular Expression"
"description": "Segmentation Regular Expression",
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
},
"split_words": {
"description": "Split Word List",
@@ -1493,4 +1522,4 @@
"helpMiddle": "or",
"helpSuffix": "."
}
}
}
@@ -23,6 +23,9 @@
"placeholder": "Search extensions...",
"marketPlaceholder": "Search market extensions..."
},
"filters": {
"all": "All"
},
"views": {
"card": "Card View",
"list": "List View"
@@ -122,10 +125,14 @@
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
},
"sort": {
"by": "Sort by",
"default": "Default",
"installTime": "Last Modified",
"name": "Name",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
"descending": "Descending"
},
@@ -543,7 +543,7 @@
},
"unified_webhook_mode": {
"description": "统一 Webhook 模式",
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
},
"webhook_uuid": {
"description": "Webhook UUID",
@@ -553,13 +553,41 @@
"description": "企业微信智能机器人的名字",
"hint": "请务必填写正确,否则无法使用一些指令。"
},
"wecom_ai_bot_connection_mode": {
"description": "企业微信智能机器人连接模式",
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey;长连接模式需要配置 BotID/Secret。"
},
"wecomaibot_friend_message_welcome_text": {
"description": "企业微信智能机器人私聊欢迎语",
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
"hint": "可选。当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
},
"wecomaibot_init_respond_text": {
"description": "企业微信智能机器人初始响应文本",
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
"hint": "可选。当机器人收到消息时,首先回复的文本内容。留空则不设置。"
},
"wecomaibot_token": {
"description": "企业微信智能机器人 Token",
"hint": "用于 Webhook 回调模式的身份验证。"
},
"wecomaibot_encoding_aes_key": {
"description": "企业微信智能机器人 EncodingAESKey",
"hint": "用于 Webhook 回调模式的消息加密解密。"
},
"wecomaibot_ws_bot_id": {
"description": "长连接 BotID",
"hint": "企业微信智能机器人长连接模式凭证 BotID。"
},
"wecomaibot_ws_secret": {
"description": "长连接 Secret",
"hint": "企业微信智能机器人长连接模式凭证 Secret。"
},
"wecomaibot_ws_url": {
"description": "长连接 WebSocket 地址",
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。"
},
"wecomaibot_heartbeat_interval": {
"description": "长连接心跳间隔",
"hint": "长连接模式心跳间隔(秒),建议 30 秒。"
},
"wpp_active_message_poll": {
"description": "是否启用主动消息轮询",
@@ -582,11 +610,11 @@
},
"msg_push_webhook_url": {
"description": "企业微信消息推送 Webhook URL",
"hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。"
"hint": "可选。用于主动消息推送,请在企微群->消息推送得到 URL。建议设置此项以带来更好的消息发送体验。"
},
"only_use_webhook_url_to_send": {
"description": "仅使用 Webhook 发送消息",
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
"hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
},
"kook_bot_token": {
"description": "机器人 Token",
@@ -848,7 +876,8 @@
]
},
"regex": {
"description": "分段正则表达式"
"description": "分段正则表达式",
"hint": "用于按正则规则识别分段点。建议使用能匹配分隔符的表达式。"
},
"split_words": {
"description": "分段词列表",
@@ -1496,4 +1525,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
}
}
@@ -23,6 +23,9 @@
"placeholder": "搜索插件...",
"marketPlaceholder": "搜索市场插件..."
},
"filters": {
"all": "全部"
},
"views": {
"card": "卡片视图",
"list": "列表视图"
@@ -122,10 +125,14 @@
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
},
"sort": {
"by": "排序方式",
"default": "默认排序",
"installTime": "最后修改时间",
"name": "名称",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
"descending": "降序"
},
@@ -27,6 +27,7 @@ const customizer = useCustomizerStore();
const theme = useTheme();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
@@ -402,15 +403,32 @@ const viewMode = computed({
});
// viewMode bot
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat') {
// chat bot
if (route.path !== '/') {
router.push('/');
// bot
// route bot
watch(() => route.fullPath, (newPath) => {
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
try {
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
} catch (e) {
console.error('Failed to save last bot route to localStorage:', e);
}
}
});
// viewMode
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
// chat bot bot
let lastBotRoute = '/';
try {
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
} catch (e) {
console.error('Failed to read last bot route from localStorage:', e);
}
router.push(lastBotRoute);
}
});
// Merry Christmas! 🎄
const isChristmas = computed(() => {
const today = new Date();
+2
View File
@@ -0,0 +1,2 @@
export const normalizeTextInput = (value: unknown): string =>
typeof value === 'string' ? value : '';
+7 -1
View File
@@ -13,9 +13,11 @@
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
:model-value="configSearchKeyword"
@update:model-value="onConfigSearchInput"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
clearable
hide-details
density="compact"
rounded="md"
@@ -211,6 +213,7 @@ import {
useConfirmDialog
} from '@/utils/confirmDialog';
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'ConfigPage',
@@ -419,6 +422,9 @@ export default {
},
methods: {
onConfigSearchInput(value) {
this.configSearchKeyword = normalizeTextInput(value);
},
extractConfigTypeFromHash(hash) {
const rawHash = String(hash || '');
const lastHashIndex = rawHash.lastIndexOf('#');
+10 -4
View File
@@ -353,10 +353,11 @@
<v-window-item value="search">
<div class="search-container pa-4">
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')"
<v-text-field :model-value="searchQuery"
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')"
append-icon="mdi-magnify" variant="outlined" class="flex-grow-1 me-2"
@click:append="searchKnowledgeBase" @keyup.enter="searchKnowledgeBase"
:placeholder="tm('search.queryPlaceholder')" hide-details></v-text-field>
:placeholder="tm('search.queryPlaceholder')" hide-details clearable></v-text-field>
<v-select v-model="topK" :items="[3, 5, 10, 20]"
:label="tm('search.resultCountLabel')" variant="outlined"
@@ -434,6 +435,7 @@
import axios from 'axios';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'KnowledgeBase',
@@ -580,6 +582,9 @@ export default {
this.getProviderList();
},
methods: {
onSearchQueryInput(value) {
this.searchQuery = normalizeTextInput(value);
},
getSelectedGitHubProxy() {
if (typeof window === "undefined" || !window.localStorage) return "";
return localStorage.getItem("githubProxyRadioValue") === "1"
@@ -903,7 +908,8 @@ export default {
},
searchKnowledgeBase() {
if (!this.searchQuery.trim()) {
const query = normalizeTextInput(this.searchQuery).trim();
if (!query) {
this.showSnackbar(this.tm('messages.pleaseEnterSearchContent'), 'warning');
return;
}
@@ -914,7 +920,7 @@ export default {
axios.get(`/api/plug/alkaid/kb/collection/search`, {
params: {
collection_name: this.currentKB.collection_name,
query: this.searchQuery,
query,
top_k: this.topK
}
})
+19 -8
View File
@@ -37,10 +37,12 @@
<h3>{{ tm('search.title') }}</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div>
<v-text-field v-model="searchMemoryUserId" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
class="mb-2"></v-text-field>
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
@keyup.enter="searchMemory" class="mb-2"></v-text-field>
<v-text-field :model-value="searchMemoryUserId"
@update:model-value="onSearchMemoryUserIdInput" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
class="mb-2" clearable></v-text-field>
<v-text-field :model-value="searchQuery"
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
@keyup.enter="searchMemory" class="mb-2" clearable></v-text-field>
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
<v-icon start>mdi-text-search</v-icon>
{{ tm('search.searchButton') }}
@@ -254,6 +256,7 @@
import axios from 'axios';
// import * as d3 from "d3"; // npm install d3
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'LongTermMemory',
@@ -336,9 +339,16 @@ export default {
this.searchResults = [];
},
methods: {
onSearchMemoryUserIdInput(value) {
this.searchMemoryUserId = normalizeTextInput(value);
},
onSearchQueryInput(value) {
this.searchQuery = normalizeTextInput(value);
},
//
searchMemory() {
if (!this.searchQuery.trim()) {
const query = normalizeTextInput(this.searchQuery).trim();
if (!query) {
this.$toast.warning(this.tm('messages.searchQueryRequired'));
return;
}
@@ -349,12 +359,13 @@ export default {
//
const params = {
query: this.searchQuery
query
};
// ID
if (this.searchMemoryUserId) {
params.user_id = this.searchMemoryUserId;
const normalizedUserId = normalizeTextInput(this.searchMemoryUserId).trim();
if (normalizedUserId) {
params.user_id = normalizedUserId;
}
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
@@ -1,7 +1,9 @@
<script setup>
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { normalizeTextInput } from "@/utils/inputValue";
const props = defineProps({
state: {
@@ -48,6 +50,9 @@ const {
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
@@ -82,6 +87,8 @@ const {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
@@ -158,10 +165,12 @@ const {
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
<v-text-field
v-model="pluginSearch"
:model-value="pluginSearch"
@update:model-value="pluginSearch = normalizeTextInput($event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -185,30 +194,64 @@ const {
</div>
<v-row class="mb-4">
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
<v-btn variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-col cols="12">
<div class="installed-toolbar">
<div class="installed-toolbar__actions">
<v-btn variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-btn
class="ml-2"
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
<v-btn
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
</div>
<div class="installed-toolbar__controls">
<v-btn-toggle
v-model="installedStatusFilter"
mandatory
divided
density="compact"
color="primary"
class="installed-status-toggle"
>
<v-btn value="all" prepend-icon="mdi-filter-variant">
{{ tm("filters.all") }}
</v-btn>
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
{{ tm("status.enabled") }}
</v-btn>
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
{{ tm("status.disabled") }}
</v-btn>
</v-btn-toggle>
<PluginSortControl
v-model="installedSortBy"
:items="installedSortItems"
:label="tm('sort.by')"
:order="installedSortOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="installedSortUsesOrder"
@update:order="installedSortOrder = $event"
/>
</div>
</div>
</v-col>
</v-row>
@@ -654,6 +697,32 @@ const {
</template>
<style scoped>
.installed-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.installed-toolbar__actions,
.installed-toolbar__controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.installed-toolbar__controls {
margin-left: auto;
justify-content: flex-end;
}
.installed-status-toggle :deep(.v-btn) {
min-height: 34px;
text-transform: none;
}
.view-mode-toggle :deep(.v-btn) {
min-width: 30px;
height: 28px;
@@ -684,6 +753,14 @@ const {
}
}
@media (max-width: 960px) {
.installed-toolbar__controls {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
}
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@@ -1,7 +1,9 @@
<script setup>
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { computed } from "vue";
import { normalizeTextInput } from "@/utils/inputValue";
const props = defineProps({
state: {
@@ -157,6 +159,13 @@ const currentSourceName = computed(() => {
const matched = customSources.value.find((s) => s.url === selectedSource.value);
return matched?.name || tm("market.defaultSource");
});
const marketSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.stars"), value: "stars" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updated"), value: "updated" },
]);
</script>
<template>
@@ -204,11 +213,13 @@ const currentSourceName = computed(() => {
</div>
<v-text-field
v-model="marketSearch"
:model-value="marketSearch"
@update:model-value="marketSearch = normalizeTextInput($event)"
class="ml-auto"
density="compact"
:label="tm('search.marketPlaceholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -327,44 +338,16 @@ const currentSourceName = computed(() => {
class="d-flex align-center"
style="gap: 8px; flex-wrap: wrap"
>
<v-select
<PluginSortControl
v-model="sortBy"
:items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' },
]"
density="compact"
variant="outlined"
hide-details
style="max-width: 150px"
>
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<v-btn
icon
v-if="sortBy !== 'default'"
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text"
density="compact"
>
<v-icon>{{
sortOrder === "desc"
? "mdi-sort-descending"
: "mdi-sort-ascending"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{
sortOrder === "desc"
? tm("sort.descending")
: tm("sort.ascending")
}}
</v-tooltip>
</v-btn>
:items="marketSortItems"
:label="tm('sort.by')"
:order="sortOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="sortBy !== 'default'"
@update:order="sortOrder = $event"
/>
</div>
</div>
+125 -14
View File
@@ -186,6 +186,9 @@ export const useExtensionPage = () => {
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
const loading_ = ref(false);
// 分页相关
@@ -253,6 +256,18 @@ export const useExtensionPage = () => {
{ title: tm("table.headers.specificType"), key: "type" },
{ title: tm("table.headers.trigger"), key: "cmd" },
]);
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updateStatus"), value: "update_status" },
]);
const installedSortUsesOrder = computed(
() => installedSortBy.value !== "default",
);
// 插件表格的表头定义
const showAuthorColumn = computed(() => width.value >= 1280);
@@ -261,16 +276,19 @@ export const useExtensionPage = () => {
{
title: tm("table.headers.name"),
key: "name",
sortable: false,
width: showAuthorColumn.value ? "24%" : "26%",
},
{
title: tm("table.headers.description"),
key: "desc",
sortable: false,
width: showAuthorColumn.value ? "32%" : "36%",
},
{
title: tm("table.headers.version"),
key: "version",
sortable: false,
width: showAuthorColumn.value ? "12%" : "14%",
},
];
@@ -279,6 +297,7 @@ export const useExtensionPage = () => {
headers.push({
title: tm("table.headers.author"),
key: "author",
sortable: false,
width: "10%",
});
}
@@ -301,33 +320,120 @@ export const useExtensionPage = () => {
}
return data;
});
const sortPluginsByName = (plugins) => {
const compareInstalledPluginNames = (left, right) =>
normalizeStr(left?.name ?? "").localeCompare(
normalizeStr(right?.name ?? ""),
undefined,
{
sensitivity: "base",
},
);
const compareInstalledPluginAuthors = (left, right) =>
normalizeStr(left?.author ?? "").localeCompare(
normalizeStr(right?.author ?? ""),
undefined,
{ sensitivity: "base" },
);
const getInstalledAtTimestamp = (plugin) => {
const parsed = Date.parse(plugin?.installed_at ?? "");
return Number.isFinite(parsed) ? parsed : null;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({ plugin, index }))
.sort((a, b) => {
const nameA = String(a.plugin?.name ?? "");
const nameB = String(b.plugin?.name ?? "");
const nameCompare = nameA.localeCompare(nameB, undefined, {
sensitivity: "base",
});
if (nameCompare !== 0) {
return nameCompare;
.map((plugin, index) => ({
plugin,
index,
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
const fallbackNameCompare = compareInstalledPluginNames(
left.plugin,
right.plugin,
);
const fallbackResult =
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
return fallbackResult;
}
if (leftTimestamp == null) {
return 1;
}
if (rightTimestamp == null) {
return -1;
}
const timeDiff =
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
return timeDiff !== 0 ? timeDiff : fallbackResult;
}
return a.index - b.index;
if (installedSortBy.value === "name") {
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
if (nameCompare !== 0) {
return installedSortOrder.value === "desc"
? -nameCompare
: nameCompare;
}
return left.index - right.index;
}
if (installedSortBy.value === "author") {
const authorCompare = compareInstalledPluginAuthors(
left.plugin,
right.plugin,
);
if (authorCompare !== 0) {
return installedSortOrder.value === "desc"
? -authorCompare
: authorCompare;
}
return fallbackResult;
}
if (installedSortBy.value === "update_status") {
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
const updateDiff =
installedSortOrder.value === "desc"
? rightHasUpdate - leftHasUpdate
: leftHasUpdate - rightHasUpdate;
return updateDiff !== 0 ? updateDiff : fallbackResult;
}
return fallbackResult;
})
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value;
const plugins = filteredExtensions.value.filter((plugin) => {
if (installedStatusFilter.value === "enabled") {
return !!plugin.activated;
}
if (installedStatusFilter.value === "disabled") {
return !plugin.activated;
}
return true;
});
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
return sortPluginsByName([...filtered]);
return sortInstalledPlugins(filtered);
});
// 过滤后的插件市场数据(带搜索)
@@ -1481,6 +1587,9 @@ export const useExtensionPage = () => {
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
@@ -1516,6 +1625,8 @@ export const useExtensionPage = () => {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
venv/
.DS_Store
node_modules/
.vitepress/cache
*dist
+530
View File
@@ -0,0 +1,530 @@
import { defineConfig } from "vitepress";
import { head } from "./config/head";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "AstrBot",
description: "AstrBot",
head: head,
rewrites: {
'zh/:rest*': ':rest*'
},
sitemap: {
hostname: "https://docs.astrbot.app",
},
lastUpdated: true,
ignoreDeadLinks: true,
locales: {
root: {
label: "简体中文",
lang: "zh-Hans",
themeConfig: {
nav: [
{ text: "主页", link: "https://astrbot.app" },
{ text: "博客", link: "https://blog.astrbot.app" },
{ text: "路线图", link: "https://astrbot.featurebase.app/roadmap" },
{ text: "HTTP API", link: "https://docs.astrbot.app/scalar.html" },
],
sidebar: [
{
text: "简介",
items: [
{ text: "关于 AstrBot", link: "/what-is-astrbot" },
{ text: "社区", link: "/community" },
{ text: "常见问题", link: "/faq" },
],
},
{
text: "部署",
base: "/deploy",
collapsed: false,
items: [
{ text: "包管理器部署", link: "/astrbot/package" },
{ text: "雨云一键云部署", link: "/astrbot/rainyun" },
{ text: "桌面客户端部署", link: "/astrbot/desktop" },
{ text: "启动器一键部署", link: "/astrbot/launcher" },
{ text: "Docker 部署", link: "/astrbot/docker" },
{ text: "Kubernetes 部署", link: "/astrbot/kubernetes" },
{ text: "宝塔面板部署", link: "/astrbot/btpanel" },
{ text: "1Panel 部署", link: "/astrbot/1panel" },
{ text: "手动部署", link: "/astrbot/cli" },
{
text: "其他部署方式",
link: "/astrbot/other-deployments",
collapsed: true,
items: [
{ text: "CasaOS 部署", link: "/astrbot/casaos" },
{ text: "优云智算 GPU 部署", link: "/astrbot/compshare" },
{ text: "社区提供的部署方式", link: "/astrbot/community-deployment" },
],
},
{
text: "支持我们",
link: "/when-deployed",
},
],
},
{
text: "接入消息平台",
base: "/platform",
items: [
{
text: "快速接入指南",
link: "/start",
},
{
text: "QQ 官方机器人",
link: "/qqofficial",
collapsed: true,
items: [
{ text: "Websockets 方式(推荐)", link: "/qqofficial/websockets" },
{ text: "Webhook 方式", link: "/qqofficial/webhook" },
],
},
{
text: "OneBot v11",
base: "/platform/aiocqhttp",
collapsed: true,
items: [
{ text: "NapCat", link: "/napcat" },
{ text: "Lagrange", link: "/lagrange" },
{ text: "其他端", link: "/others" },
],
},
{ text: "企微应用", link: "/wecom" },
{ text: "企微智能机器人", link: "/wecom_ai_bot" },
{ text: "微信公众号", link: "/weixin-official-account" },
{ text: "飞书", link: "/lark" },
{ text: "钉钉", link: "/dingtalk" },
{ text: "Telegram", link: "/telegram" },
{ text: "LINE", link: "/line" },
{ text: "Slack", link: "/slack" },
{ text: "Misskey", link: "/misskey" },
{ text: "Discord", link: "/discord" },
{ text: "KOOK", link: "/kook" },
{
text: "Satori",
base: "/platform/satori",
collapsed: true,
items: [
{ text: "使用 LLOneBot", link: "/llonebot" },
{ text: "使用 server-satori", link: "/server-satori" },
],
},
{
text: "社区提供",
collapsed: false,
items: [
{ text: "Matrix", link: "/matrix" },
{ text: "VoceChat", link: "/vocechat" },
],
},
],
},
{
text: "接入 AI",
base: "/providers",
items: [
{
text: "✨ 接入模型服务",
link: "/start",
collapsed: true,
items: [
{ text: "NewAPI", link: "/newapi" },
{ text: "AIHubMix", link: "/aihubmix" },
{ text: "PPIO 派欧云", link: "/ppio" },
{ text: "硅基流动", link: "/siliconflow" },
{ text: "小马算力", link: "/tokenpony" },
{ text: "302.AI", link: "/302ai" },
{ text: "Ollama", link: "/provider-ollama" },
{ text: "LMStudio", link: "/provider-lmstudio" },
]
},
{
text: "⚙️ Agent 执行器",
link: "/agent-runners",
collapsed: false,
items: [
{ text: "内置 Agent 执行器", link: "/agent-runners/astrbot-agent-runner" },
{ text: "Dify", link: "/agent-runners/dify" },
{ text: "扣子 Coze", link: "/agent-runners/coze" },
{ text: "阿里云百炼应用", link: "/agent-runners/dashscope" },
{ text: "DeerFlow", link: "/agent-runners/deerflow" },
]
},
],
},
{
text: "使用",
base: "/use",
items: [
{ text: "WebUI", link: "/webui" },
{ text: "插件", link: "/plugin" },
{ text: "内置指令", link: "/command" },
{ text: "工具使用 Tools", link: "/function-calling" },
{ text: "技能 Skills", link: "/skills" },
{ text: "SubAgent 编排", link: "/subagent" },
{ text: "主动型 Agent 能力", link: "/proactive-agent" },
{ text: "MCP", link: "/mcp" },
{ text: "网页搜索", link: "/websearch" },
{ text: "知识库", link: "/knowledge-base" },
{ text: "自定义规则", link: "/custom-rules" },
{ text: "Agent 执行器", link: "/agent-runner" },
{ text: "统一 Webhook 模式", link: "/unified-webhook" },
{ text: "自动上下文压缩", link: "/context-compress" },
{ text: "Agent 沙箱环境", link: "/astrbot-agent-sandbox" },
],
},
{
text: "开发",
base: "/dev",
collapsed: true,
items: [
{
text: "插件开发",
base: "/dev/star",
collapsed: true,
items: [
{ text: "🌠 从这里开始", link: "/plugin-new" },
{ text: "最小实例", link: "/guides/simple" },
{ text: "接收消息事件", link: "/guides/listen-message-event" },
{ text: "发送消息", link: "/guides/send-message" },
{ text: "插件配置", link: "/guides/plugin-config" },
{ text: "调用 AI", link: "/guides/ai" },
{ text: "存储", link: "/guides/storage" },
{ text: "文转图", link: "/guides/html-to-pic" },
{ text: "会话控制器", link: "/guides/session-control" },
{ text: "杂项", link: "/guides/other" },
{ text: "发布插件", link: "/plugin-publish" },
{ text: "插件指南(旧)", link: "/plugin" },
],
},
{
text: "接入平台适配器",
link: "/plugin-platform-adapter",
},
{
text: "AstrBot HTTP API",
link: "/openapi",
},
{
text: "AstrBot 配置文件",
link: "/astrbot-config",
},
],
},
{
text: "其他",
base: "/others",
collapsed: true,
items: [
{ text: "自部署文转图", link: "/self-host-t2i" },
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
],
},
{
text: "开源之夏",
base: "/ospp",
collapsed: true,
items: [{ text: "OSPP 2025", link: "/2025" }],
},
],
outline: {
level: 'deep',
label: '目录',
},
darkModeSwitchLabel: '切换日光/暗黑模式',
sidebarMenuLabel: '文章',
returnToTopLabel: '返回顶部',
docFooter: {
prev: '上一篇',
next: '下一篇'
},
editLink: {
pattern: 'https://github.com/AstrBotdevs/AstrBot/edit/master/docs/:path',
text: '发现文档有问题?在 GitHub 上编辑此页',
},
logo: '/logo_prod.png',
socialLinks: [
{ icon: "github", link: "https://github.com/AstrBotDevs/AstrBot" },
],
footer: {
message: 'Deployed on&nbsp' +
'<a href="https://www.rainyun.com/NjY3OTQ5_" class="deployment-link" style="display: inline-flex; align-items: center;">' +
'<img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo">' +
'</a>',
}
}
},
en: {
label: "English",
lang: "en-US",
themeConfig: {
nav: [
{ text: "Home", link: "https://astrbot.app" },
{ text: "Blog", link: "https://blog.astrbot.app" },
{ text: "Roadmap", link: "https://astrbot.featurebase.app/roadmap" },
{ text: "HTTP API", link: "https://docs.astrbot.app/scalar.html" },
],
sidebar: [
{
text: "Introduction",
items: [
{ text: "What is AstrBot", link: "/en/what-is-astrbot" },
{ text: "Community", link: "/en/community" },
{ text: "FAQ", link: "/en/faq" },
],
},
{
text: "Deployment",
base: "/en/deploy",
collapsed: false,
items: [
{ text: "Package Manager", link: "/astrbot/package" },
{ text: "One-click Launcher", link: "/astrbot/launcher" },
{ text: "Docker", link: "/astrbot/docker" },
{ text: "Kubernetes", link: "/astrbot/kubernetes" },
{ text: "BT Panel", link: "/astrbot/btpanel" },
{ text: "1Panel", link: "/astrbot/1panel" },
{ text: "Manual", link: "/astrbot/cli" },
{
text: "Other Deployments",
link: "/astrbot/other-deployments",
collapsed: true,
items: [
{ text: "CasaOS", link: "/astrbot/casaos" },
{ text: "Compshare GPU", link: "/astrbot/compshare" },
{ text: "Community-provided Deployment", link: "/astrbot/community-deployment" },
],
},
{
text: "Support Us",
link: "/when-deployed",
},
],
},
{
text: "Messaging Platforms",
base: "/en/platform",
collapsed: false,
items: [
{
text: "Quick Start",
link: "/start",
},
{
text: "QQ Official Bot",
link: "/qqofficial",
collapsed: true,
items: [
{ text: "Websockets", link: "/qqofficial/websockets" },
{ text: "Webhook", link: "/qqofficial/webhook" },
],
},
{
text: "OneBot v11",
base: "/en/platform/aiocqhttp",
collapsed: true,
items: [
{ text: "NapCat", link: "/napcat" },
{ text: "Lagrange", link: "/lagrange" },
{ text: "Other Clients", link: "/others" },
],
},
{ text: "WeCom Application", link: "/wecom" },
{ text: "WeCom AI Bot", link: "/wecom_ai_bot" },
{ text: "WeChat Official Account", link: "/weixin-official-account" },
{ text: "Lark", link: "/lark" },
{ text: "DingTalk", link: "/dingtalk" },
{ text: "Telegram", link: "/telegram" },
{ text: "LINE", link: "/line" },
{ text: "Slack", link: "/slack" },
{ text: "Misskey", link: "/misskey" },
{ text: "Discord", link: "/discord" },
{
text: "Satori",
base: "/en/platform/satori",
collapsed: true,
items: [
{ text: "Using LLOneBot", link: "/llonebot" },
{ text: "Using server-satori", link: "/server-satori" },
],
},
{
text: "Community-provided",
collapsed: false,
items: [
{ text: "Matrix", link: "/matrix" },
{ text: "KOOK", link: "/kook" },
{ text: "VoceChat", link: "/vocechat" },
],
},
],
},
{
text: "AI Integration",
base: "/en/providers",
collapsed: false,
items: [
{
text: "✨ Model Providers",
link: "/start",
collapsed: true,
items: [
{ text: "NewAPI", link: "/newapi" },
{ text: "AIHubMix", link: "/aihubmix" },
{ text: "PPIO Cloud", link: "/ppio" },
{ text: "SiliconFlow", link: "/siliconflow" },
{ text: "TokenPony", link: "/tokenpony" },
{ text: "302.AI", link: "/302ai" },
{ text: "Ollama", link: "/provider-ollama" },
{ text: "LMStudio", link: "/provider-lmstudio" },
],
},
{
text: "⚙️ Agent Runners",
link: "/agent-runners",
collapsed: false,
items: [
{ text: "Built-in Agent Runner", link: "/agent-runners/astrbot-agent-runner" },
{ text: "Dify", link: "/agent-runners/dify" },
{ text: "Coze", link: "/agent-runners/coze" },
{ text: "Alibaba Bailian", link: "/agent-runners/dashscope" },
{ text: "DeerFlow", link: "/agent-runners/deerflow" },
],
},
],
},
{
text: "Usage",
base: "/en/use",
collapsed: true,
items: [
{ text: "WebUI", link: "/webui" },
{ text: "Plugins", link: "/plugin" },
{ text: "Built-in Commands", link: "/command" },
{ text: "Tool Use", link: "/function-calling" },
{ text: "Anthropic Skills", link: "/skills" },
{ text: "SubAgent Orchestration", link: "/subagent" },
{ text: "Proactive Tasks", link: "/proactive-agent" },
{ text: "MCP", link: "/mcp" },
{ text: "Web Search", link: "/websearch" },
{ text: "Knowledge Base", link: "/knowledge-base" },
{ text: "Custom Rules", link: "/custom-rules" },
{ text: "Agent Runner", link: "/agent-runner" },
{ text: "Unified Webhook Mode", link: "/unified-webhook" },
{ text: "Auto Context Compression", link: "/context-compress" },
{ text: "Agent Sandbox", link: "/astrbot-agent-sandbox" },
],
},
{
text: "Development",
base: "/en/dev",
collapsed: true,
items: [
{
text: "Plugin Development",
base: "/en/dev/star",
collapsed: true,
items: [
{ text: "🌠 Getting Started", link: "/plugin-new" },
{ text: "Minimal Example", link: "/guides/simple" },
{ text: "Listen to Message Events", link: "/guides/listen-message-event" },
{ text: "Send Messages", link: "/guides/send-message" },
{ text: "Plugin Configuration", link: "/guides/plugin-config" },
{ text: "AI", link: "/guides/ai" },
{ text: "Storage", link: "/guides/storage" },
{ text: "HTML to Image", link: "/guides/html-to-pic" },
{ text: "Session Control", link: "/guides/session-control" },
{ text: "Publish Plugin", link: "/plugin-publish" },
],
},
{
text: "Platform Adapter Integration",
link: "/plugin-platform-adapter",
},
{
text: "AstrBot HTTP API",
link: "/openapi",
},
{
text: "AstrBot Configuration File",
link: "/astrbot-config",
},
],
},
{
text: "Others",
base: "/en/others",
collapsed: true,
items: [
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
],
},
{
text: "Open Source Summer",
base: "/en/ospp",
collapsed: true,
items: [{ text: "OSPP 2025", link: "/2025" }],
},
],
outline: {
level: 'deep',
label: 'On this page',
},
darkModeSwitchLabel: 'Toggle dark mode',
sidebarMenuLabel: 'Menu',
returnToTopLabel: 'Return to top',
docFooter: {
prev: 'Previous',
next: 'Next'
},
editLink: {
pattern: 'https://github.com/AstrBotdevs/AstrBot/edit/master/docs/:path',
text: 'Edit this page on GitHub',
},
logo: '/logo_prod.png',
socialLinks: [
{ icon: "github", link: "https://github.com/AstrBotDevs/AstrBot" },
],
footer: {
message: 'Deployed on&nbsp' +
'<a href="https://www.rainyun.com/NjY3OTQ5_" class="deployment-link" style="display: inline-flex; align-items: center;">' +
'<img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo">' +
'</a>',
}
}
},
},
themeConfig: {
search: {
provider: "local",
options: {
locales: {
root: {
translations: {
button: {
buttonText: "搜索文档",
buttonAriaLabel: "搜索文档",
},
modal: {
noResultsText: "无法找到相关结果",
resetButtonTitle: "清除查询条件",
footer: {
selectText: "选择",
navigateText: "切换",
closeText: "关闭",
},
},
},
},
},
},
},
}
});
+47
View File
@@ -0,0 +1,47 @@
import type { HeadConfig } from "vitepress";
export const head: HeadConfig[] = [
// --- Google Fonts ---
["link", { rel: "preconnect", href: "https://fonts.googleapis.cn", crossorigin: "" }],
["link", { rel: "dns-prefetch", href: "https://fonts.googleapis.cn" }],
["link", { rel: "preconnect", href: "https://fonts.gstatic.cn", crossorigin: "" }],
["link", { rel: "dns-prefetch", href: "https://fonts.gstatic.cn" }],
["link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap" }],
// --- 基础和SEO元数据 ---
["link", { rel: "icon", href: "/logo.png" }],
["meta", { name: "description", content: "AstrBot" }],
[
"meta",
{ name: "viewport", content: "width=device-width, initial-scale=1.0" },
],
/* // --- Open Graph (OG) () ---
["meta", { property: "og:type", content: "website" }],
["meta", { property: "og:locale", content: "zh_CN" }],
["meta", { property: "og:title", content: "AstrBot" }],
["meta", { property: "og:description", content: "AstrBot" }],
["meta", { property: "og:url", content: "https://docs.astrbot.app" }],
["meta", { property: "og:site_name", content: "AstrBot" }],
[
"meta",
{
property: "og:image",
content: "/",
},
],
[
"meta",
{ property: "og:image:alt", content: "AstrBot" },
],
["meta", { property: "og:image:width", content: "1200" }],
["meta", { property: "og:image:height", content: "630" }],
["meta", { property: "og:image:type", content: "image/png" }],
// --- Twitter Card 元数据 ---
["meta", { name: "twitter:card", content: "summary_large_image" }],
["meta", { name: "twitter:site", content: "@AstrBot" }],*/
// --- Umami Analytics ---
["script", { defer: "", src: "https://cloud.umami.is/script.js", "data-website-id": "9c3f777e-9f4a-4b79-a5c3-ff94f5dca8f9" }],
];
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
const props = defineProps({
shareText: {
type: String,
default: "分享链接",
},
copiedText: {
type: String,
default: "已复制!",
},
includeQuery: {
type: Boolean,
default: false,
},
includeHash: {
type: Boolean,
default: false,
},
copiedTimeout: {
type: Number,
default: 2000,
},
})
defineOptions({ name: "ArticleShare" })
const copied = ref(false)
const isClient =
typeof window !== "undefined" && typeof document !== "undefined"
const shareLink = computed(() => {
if (!isClient) return ""
const { origin, pathname, search, hash } = window.location
const finalSearch = props.includeQuery ? search : ""
const finalHash = props.includeHash ? hash : ""
return `${origin}${pathname}${finalSearch}${finalHash}`
})
async function copyToClipboard() {
if (copied.value || !isClient) return
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareLink.value)
} else {
const input = document.createElement("input")
input.setAttribute("readonly", "readonly")
input.setAttribute("value", shareLink.value)
document.body.appendChild(input)
input.select()
document.execCommand("copy")
document.body.removeChild(input)
}
copied.value = true
setTimeout(() => {
copied.value = false
}, props.copiedTimeout)
} catch (error) {
console.error("复制链接失败:", error)
}
}
const shareIconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
<polyline points="16 6 12 2 8 6"></polyline>
<line x1="12" y1="2" x2="12" y2="15"></line>
</svg>
`
const copiedIconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"></path>
</svg>
`
// onMounted(() => {
// const script = document.createElement('script')
// script.src = 'https://cdn.wwads.cn/js/makemoney.js'
// script.async = true
// document.head.appendChild(script)
// })
</script>
<template>
<div style="display: flex; justify-content: center; align-items: center; flex-direction: column;">
<div class="article-share">
<button :class="['article-share__button', { copied: copied }]"
:aria-label="copied ? props.copiedText : props.shareText" aria-live="polite" @click="copyToClipboard">
<div v-if="!copied" class="content-wrapper">
<span class="icon" v-html="shareIconSvg"></span>
{{ props.shareText }}
</div>
<div v-else class="content-wrapper">
<span class="icon" v-html="copiedIconSvg"></span>
{{ props.copiedText }}
</div>
</button>
</div>
<!-- <div class="wwads-cn wwads-vertical sponsors" data-id="380" style="max-width:180px"></div> -->
</div>
</template>
<style scoped>
.article-share {
padding: 14px 0;
width: 100%;
}
.article-share__button {
display: flex;
justify-content: center;
align-items: center;
font-weight: 500;
font-size: 14px;
position: relative;
z-index: 1;
transition: all 0.4s var(--ease-out-cubic, cubic-bezier(0.33, 1, 0.68, 1));
cursor: pointer;
border: 1px solid transparent;
border-radius: 14px;
padding: 7px 14px;
width: 100%;
overflow: hidden;
color: var(--vp-c-text-1, #333);
background-color: var(--vp-c-bg-alt, #f6f6f7);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.02);
will-change: transform, box-shadow;
}
.article-share__button::before {
content: "";
position: absolute;
top: 0;
left: -100%;
z-index: -1;
transition: left 0.6s ease;
background-color: var(--vp-c-brand-soft, #ddf4ff);
width: 100%;
height: 100%;
}
.article-share__button:hover {
transform: translateY(-1px);
border-color: var(--vp-c-brand-soft, #ddf4ff);
background-color: var(--vp-c-brand-soft, #ddf4ff);
}
.article-share__button:active {
transform: scale(0.9);
}
.article-share__button.copied {
color: var(--vp-c-brand-1, #007acc);
/* 增加了备用颜色 */
background-color: var(--vp-c-brand-soft, #ddf4ff);
}
.article-share__button.copied::before {
left: 0;
background-color: var(--vp-c-brand-soft, #ddf4ff);
}
.content-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.icon {
display: inline-flex;
align-items: center;
margin-right: 6px;
}
.sponsors {
max-width: 100%;
margin: 0 !important;
background-color: transparent !important;
}
.sponsors .wwads-text {
color: var(--vp-c-text-1) !important;
transition-property: color;
transition-duration: 500ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
@@ -0,0 +1,7 @@
<template>
<div style="display: flex; justify-content: center; align-items: center; margin-top: 16px; gap: 12px;">
<span style="font-size: 13px; color: #666; font-style: italic;;">Deployed on</span>
<a href="https://www.rainyun.com/NjY3OTQ1_"><img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo"></a>
</div>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup>
import { useRoute } from 'vitepress'
import { computed, provide, useSlots, watch } from 'vue'
import VPBackdrop from 'vitepress/dist/client/theme-default/components/VPBackdrop.vue'
import VPContent from 'vitepress/dist/client/theme-default/components/VPContent.vue'
import VPFooter from 'vitepress/dist/client/theme-default/components/VPFooter.vue'
import VPLocalNav from 'vitepress/dist/client/theme-default/components/VPLocalNav.vue'
import VPNav from 'vitepress/dist/client/theme-default/components/VPNav.vue'
import VPSidebar from 'vitepress/dist/client/theme-default/components/VPSidebar.vue'
import VPSkipLink from 'vitepress/dist/client/theme-default/components/VPSkipLink.vue'
import { useData } from 'vitepress/dist/client/theme-default/composables/data'
import { useCloseSidebarOnEscape, useSidebar } from 'vitepress/dist/client/theme-default/composables/sidebar'
import SectionTabs from './SectionTabs.vue'
const {
isOpen: isSidebarOpen,
open: openSidebar,
close: closeSidebar
} = useSidebar()
const route = useRoute()
watch(() => route.path, closeSidebar)
useCloseSidebarOnEscape(isSidebarOpen, closeSidebar)
const { frontmatter } = useData()
const sidebarScopeClass = computed(() => {
const path = route.path
const normalizedPath = path
.replace(/\.html$/, '')
.replace(/\/$/, '') || '/'
if (
normalizedPath === '/what-is-astrbot' || normalizedPath === '/community' || normalizedPath === '/faq'
|| path.startsWith('/deploy/') || path.startsWith('/others/') || path.startsWith('/ospp/')
|| normalizedPath === '/en/what-is-astrbot' || normalizedPath === '/en/community' || normalizedPath === '/en/faq'
|| path.startsWith('/en/deploy/') || path.startsWith('/en/others/') || path.startsWith('/en/ospp/')
)
return 'sidebar-scope-intro-deploy'
if (path.startsWith('/platform/') || path.startsWith('/en/platform/'))
return 'sidebar-scope-platform'
if (path.startsWith('/providers/') || path.startsWith('/en/providers/'))
return 'sidebar-scope-providers'
if (path.startsWith('/use/') || path.startsWith('/en/use/'))
return 'sidebar-scope-use'
if (path.startsWith('/dev/') || path.startsWith('/en/dev/'))
return 'sidebar-scope-dev'
return ''
})
const slots = useSlots()
const heroImageSlotExists = computed(() => !!slots['home-hero-image'])
provide('hero-image-slot-exists', heroImageSlotExists)
</script>
<template>
<div
v-if="frontmatter.layout !== false"
class="Layout"
:class="[frontmatter.pageClass, sidebarScopeClass]"
>
<slot name="layout-top" />
<VPSkipLink />
<VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar" />
<VPNav>
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
<template #nav-bar-title-after><slot name="nav-bar-title-after" /></template>
<template #nav-bar-content-before><slot name="nav-bar-content-before" /></template>
<template #nav-bar-content-after><slot name="nav-bar-content-after" /></template>
<template #nav-screen-content-before><slot name="nav-screen-content-before" /></template>
<template #nav-screen-content-after><slot name="nav-screen-content-after" /></template>
</VPNav>
<SectionTabs />
<VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" />
<VPSidebar :open="isSidebarOpen">
<template #sidebar-nav-before><slot name="sidebar-nav-before" /></template>
<template #sidebar-nav-after><slot name="sidebar-nav-after" /></template>
</VPSidebar>
<VPContent>
<template #page-top><slot name="page-top" /></template>
<template #page-bottom><slot name="page-bottom" /></template>
<template #not-found><slot name="not-found" /></template>
<template #home-hero-before><slot name="home-hero-before" /></template>
<template #home-hero-info-before><slot name="home-hero-info-before" /></template>
<template #home-hero-info><slot name="home-hero-info" /></template>
<template #home-hero-info-after><slot name="home-hero-info-after" /></template>
<template #home-hero-actions-after><slot name="home-hero-actions-after" /></template>
<template #home-hero-image><slot name="home-hero-image" /></template>
<template #home-hero-after><slot name="home-hero-after" /></template>
<template #home-features-before><slot name="home-features-before" /></template>
<template #home-features-after><slot name="home-features-after" /></template>
<template #doc-footer-before><slot name="doc-footer-before" /></template>
<template #doc-before><slot name="doc-before" /></template>
<template #doc-after><slot name="doc-after" /></template>
<template #doc-top><slot name="doc-top" /></template>
<template #doc-bottom><slot name="doc-bottom" /></template>
<template #aside-top><slot name="aside-top" /></template>
<template #aside-bottom><slot name="aside-bottom" /></template>
<template #aside-outline-before><slot name="aside-outline-before" /></template>
<template #aside-outline-after><slot name="aside-outline-after" /></template>
<template #aside-ads-before><slot name="aside-ads-before" /></template>
<template #aside-ads-after><slot name="aside-ads-after" /></template>
</VPContent>
<VPFooter />
<slot name="layout-bottom" />
</div>
<Content v-else />
</template>
<style scoped>
.Layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
</style>
@@ -0,0 +1,73 @@
<script setup>
import { useRouter } from 'vitepress'
const router = useRouter()
const goHome = () => {
router.go('/')
}
</script>
<template>
<div class="NotFound">
<img src="/404-seio.png" alt="404 Not Found" class="not-found-image" />
<h1 class="not-found-title">😢 你来到了未知的领域页面不存在</h1>
<p class="not-found-desc">请点击左上角 Logo 返回首页或点击下方按钮</p>
<button @click="goHome" class="not-found-button">返回首页</button>
</div>
</template>
<style scoped>
.NotFound {
padding: 4rem 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.not-found-image {
max-width: 400px;
width: 100%;
margin-bottom: 2rem;
}
.not-found-title {
font-size: 1.5rem;
margin-bottom: 1rem;
color: var(--vp-c-text-1);
}
.not-found-desc {
font-size: 1rem;
margin-bottom: 2rem;
color: var(--vp-c-text-2);
}
.not-found-button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
color: #fff;
background-color: var(--vp-c-brand-1);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.not-found-button:hover {
background-color: var(--vp-c-brand-2);
}
@media (max-width: 768px) {
.not-found-image {
max-width: 300px;
}
.not-found-title {
font-size: 1.25rem;
}
}
</style>
@@ -0,0 +1,121 @@
<script setup>
import { computed } from 'vue'
import { useData, useRoute } from 'vitepress'
const route = useRoute()
const { frontmatter } = useData()
const isEnglish = computed(() => route.path.startsWith('/en/'))
const zhTabs = [
{
text: '简介和部署',
link: '/what-is-astrbot',
matchers: ['/what-is-astrbot', '/community', '/faq', '/deploy/', '/others/', '/ospp/']
},
{ text: '接入消息平台', link: '/platform/start', matchers: ['/platform/'] },
{ text: '接入 AI', link: '/providers/start', matchers: ['/providers/'] },
{ text: '使用', link: '/use/webui', matchers: ['/use/'] },
{ text: '开发', link: '/dev/star/plugin-new', matchers: ['/dev/'] }
]
const enTabs = [
{
text: 'Intro & Deploy',
link: '/en/what-is-astrbot',
matchers: ['/en/what-is-astrbot', '/en/community', '/en/faq', '/en/deploy/', '/en/others/', '/en/ospp/']
},
{ text: 'Messaging Platforms', link: '/en/platform/start', matchers: ['/en/platform/'] },
{ text: 'AI Integration', link: '/en/providers/start', matchers: ['/en/providers/'] },
{ text: 'Usage', link: '/en/use/webui', matchers: ['/en/use/'] },
{ text: 'Development', link: '/en/dev/star/plugin-new', matchers: ['/en/dev/'] }
]
const tabs = computed(() => (isEnglish.value ? enTabs : zhTabs))
const isHome = computed(() => route.path === '/' || route.path === '/en/')
const shouldShow = computed(() => frontmatter.value.layout !== false && frontmatter.value.layout !== 'home' && !isHome.value)
function isActive(tab) {
return tab.matchers.some(prefix => route.path.startsWith(prefix))
}
</script>
<template>
<template v-if="shouldShow">
<div class="VPSectionTabsPlaceholder" aria-hidden="true" />
<div class="VPSectionTabs">
<div class="container">
<a
v-for="tab in tabs"
:key="tab.link"
class="tab"
:class="{ active: isActive(tab) }"
:href="tab.link"
>
{{ tab.text }}
</a>
</div>
</div>
</template>
</template>
<style scoped>
.VPSectionTabs {
display: none;
}
.VPSectionTabsPlaceholder {
display: none;
}
@media (min-width: 1280px) {
.VPSectionTabsPlaceholder {
display: block;
height: var(--vp-section-tabs-height, 44px);
}
.VPSectionTabs {
display: block;
position: fixed;
left: 0;
right: 0;
top: calc(var(--vp-layout-top-height, 0px) + var(--vp-nav-height));
z-index: 26;
border-bottom: 1px solid var(--vp-c-gutter);
background-color: var(--vp-nav-bg-color);
}
.container {
margin: 0 auto;
max-width: var(--vp-layout-max-width);
display: flex;
align-items: flex-end;
gap: 10px;
box-sizing: border-box;
height: var(--vp-section-tabs-height, 44px);
padding: 0 32px 8px;
}
.tab {
border-radius: 999px;
padding: 6px 12px;
font-size: 13px;
line-height: 20px;
color: var(--vp-c-text-2);
white-space: nowrap;
transition: color 0.2s ease, background-color 0.2s ease;
}
.tab:hover {
color: var(--vp-c-text-1);
background-color: var(--vp-c-default-soft);
}
.tab.active {
color: var(--vp-c-brand-1);
background-color: var(--vp-c-brand-soft);
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
// https://vitepress.dev/guide/custom-theme
import { h } from 'vue'
import DefaultTheme from 'vitepress/theme'
import './styles/style.css'
import './styles/custom-block.css'
import './styles/font.css'
import Layout from './components/Layout.vue'
import ArticleShare from './components/ArticleShare.vue'
import NotFound from './components/NotFound.vue'
/** @type {import('vitepress').Theme} */
export default {
extends: DefaultTheme,
Layout() {
return h(Layout, null, {
// https://vitepress.dev/guide/extending-default-theme#layout-slots
'aside-outline-after': () => h(ArticleShare),
'not-found': () => h(NotFound)
})
}
}
@@ -0,0 +1,185 @@
/* .vitepress/theme/style/custom-block.css */
/* 深浅色卡 */
:root {
--custom-block-info-left: #cccccc;
--custom-block-info-bg: #fafafa;
--custom-block-tip-left: #009400;
--custom-block-tip-bg: #b6dcc7;
--custom-block-warning-left: #e6a700;
--custom-block-warning-bg: #ffe69d;
--custom-block-danger-left: #e13238;
--custom-block-danger-bg: #ffebec;
--custom-block-note-left: #4cb3d4;
--custom-block-note-bg: #d6eff7;
--custom-block-important-left: #a371f7;
--custom-block-important-bg: #f4eefe;
--custom-block-caution-left: #e0575b;
--custom-block-caution-bg: #fde4e8;
}
.dark {
--custom-block-info-left: #cccccc;
--custom-block-info-bg: #474748;
--custom-block-tip-left: #009400;
--custom-block-tip-bg: #003100;
--custom-block-warning-left: #e6a700;
--custom-block-warning-bg: #4d3800;
--custom-block-danger-left: #e13238;
--custom-block-danger-bg: #4b1113;
--custom-block-note-left: #4cb3d4;
--custom-block-note-bg: #193c47;
--custom-block-important-left: #a371f7;
--custom-block-important-bg: #230555;
--custom-block-caution-left: #e0575b;
--custom-block-caution-bg: #391c22;
}
/* 标题字体大小 */
.custom-block-title {
font-size: 16px;
}
/* info容器:背景色、左侧 */
.custom-block.info {
background-color: var(--custom-block-info-bg);
}
/* info容器:svg图 */
.custom-block.info [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%23ccc'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -1px;
}
/* 提示容器:边框色、背景色、左侧 */
.custom-block.tip {
background-color: var(--custom-block-tip-bg);
}
/* 提示容器:svg图 */
.custom-block.tip [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23009400' d='M7.941 18c-.297-1.273-1.637-2.314-2.187-3a8 8 0 1 1 12.49.002c-.55.685-1.888 1.726-2.185 2.998H7.94zM16 20v1a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h8zm-3-9.995V6l-4.5 6.005H11v4l4.5-6H13z'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -2px;
}
/* 警告容器:背景色、左侧 */
.custom-block.warning {
background-color: var(--custom-block-warning-bg);
}
/* 警告容器:svg图 */
.custom-block.warning [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M576.286 752.57v-95.425q0-7.031-4.771-11.802t-11.3-4.772h-96.43q-6.528 0-11.3 4.772t-4.77 11.802v95.424q0 7.031 4.77 11.803t11.3 4.77h96.43q6.528 0 11.3-4.77t4.77-11.803zm-1.005-187.836 9.04-230.524q0-6.027-5.022-9.543-6.529-5.524-12.053-5.524H456.754q-5.524 0-12.053 5.524-5.022 3.516-5.022 10.547l8.538 229.52q0 5.023 5.022 8.287t12.053 3.265h92.913q7.032 0 11.803-3.265t5.273-8.287zM568.25 95.65l385.714 707.142q17.578 31.641-1.004 63.282-8.538 14.564-23.354 23.102t-31.892 8.538H126.286q-17.076 0-31.892-8.538T71.04 866.074q-18.582-31.641-1.004-63.282L455.75 95.65q8.538-15.57 23.605-24.61T512 62t32.645 9.04 23.605 24.61z' fill='%23e6a700'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
}
/* 危险容器:背景色、左侧 */
.custom-block.danger {
background-color: var(--custom-block-danger-bg);
}
/* 危险容器:svg图 */
.custom-block.danger [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -1px;
}
/* 提醒容器:背景色、左侧 */
.custom-block.note {
background-color: var(--custom-block-note-bg);
}
/* 提醒容器:svg图 */
.custom-block.note [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%234cb3d4'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -1px;
}
/* 重要容器:背景色、左侧 */
.custom-block.important {
background-color: var(--custom-block-important-bg);
}
/* 重要容器:svg图 */
.custom-block.important [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 981.333a84.992 84.992 0 0 1-84.907-84.906h169.814A84.992 84.992 0 0 1 512 981.333zm384-128H128v-42.666l85.333-85.334v-256A298.325 298.325 0 0 1 448 177.92V128a64 64 0 0 1 128 0v49.92a298.325 298.325 0 0 1 234.667 291.413v256L896 810.667v42.666zm-426.667-256v85.334h85.334v-85.334h-85.334zm0-256V512h85.334V341.333h-85.334z' fill='%23a371f7'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -1px;
}
/* 注意容器:背景色、左侧 */
.custom-block.caution {
background-color: var(--custom-block-caution-bg);
}
/* 注意容器:svg图 */
.custom-block.caution [class*="custom-block-title"]::before {
content: '';
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
position: relative;
margin-right: 4px;
left: -5px;
top: -1px;
}
+5
View File
@@ -0,0 +1,5 @@
/* Keep only the top-left navbar title in Outfit; use VitePress defaults elsewhere. */
.VPNavBarTitle .title,
.VPNavBarTitle .title .text {
font-family: "Outfit", sans-serif !important;
}
+358
View File
@@ -0,0 +1,358 @@
/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
*/
/**
* Colors
*
* Each colors have exact same color scale system with 3 levels of solid
* colors with different brightness, and 1 soft color.
*
* - `XXX-1`: The most solid color used mainly for colored text. It must
* satisfy the contrast ratio against when used on top of `XXX-soft`.
*
* - `XXX-2`: The color used mainly for hover state of the button.
*
* - `XXX-3`: The color for solid background, such as bg color of the button.
* It must satisfy the contrast ratio with pure white (#ffffff) text on
* top of it.
*
* - `XXX-soft`: The color used for subtle background such as custom container
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
* on top of it.
*
* The soft color must be semi transparent alpha channel. This is crucial
* because it allows adding multiple "soft" colors on top of each other
* to create a accent, such as when having inline code block inside
* custom containers.
*
* - `default`: The color used purely for subtle indication without any
* special meanings attached to it such as bg color for menu hover state.
*
* - `brand`: Used for primary brand colors, such as link text, button with
* brand theme, etc.
*
* - `tip`: Used to indicate useful information. The default theme uses the
* brand color for this by default.
*
* - `warning`: Used to indicate warning to the users. Used in custom
* container, badges, etc.
*
* - `danger`: Used to show error, or dangerous message to the users. Used
* in custom container, badges, etc.
* -------------------------------------------------------------------------- */
:root {
--vp-c-default-1: var(--vp-c-gray-1);
--vp-c-default-2: var(--vp-c-gray-2);
--vp-c-default-3: var(--vp-c-gray-3);
--vp-c-default-soft: var(--vp-c-gray-soft);
--vp-c-brand-1: var(--vp-c-indigo-1);
--vp-c-brand-2: var(--vp-c-indigo-2);
--vp-c-brand-3: var(--vp-c-indigo-3);
--vp-c-brand-soft: var(--vp-c-indigo-soft);
--vp-c-tip-1: var(--vp-c-brand-1);
--vp-c-tip-2: var(--vp-c-brand-2);
--vp-c-tip-3: var(--vp-c-brand-3);
--vp-c-tip-soft: var(--vp-c-brand-soft);
--vp-c-warning-1: var(--vp-c-yellow-1);
--vp-c-warning-2: var(--vp-c-yellow-2);
--vp-c-warning-3: var(--vp-c-yellow-3);
--vp-c-warning-soft: var(--vp-c-yellow-soft);
--vp-c-danger-1: var(--vp-c-red-1);
--vp-c-danger-2: var(--vp-c-red-2);
--vp-c-danger-3: var(--vp-c-red-3);
--vp-c-danger-soft: var(--vp-c-red-soft);
}
/**
* Component: Button
* -------------------------------------------------------------------------- */
:root {
--vp-button-brand-border: transparent;
--vp-button-brand-text: var(--vp-c-white);
--vp-button-brand-bg: var(--vp-c-brand-3);
--vp-button-brand-hover-border: transparent;
--vp-button-brand-hover-text: var(--vp-c-white);
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
--vp-button-brand-active-border: transparent;
--vp-button-brand-active-text: var(--vp-c-white);
--vp-button-brand-active-bg: var(--vp-c-brand-1);
}
/**
* Component: Home
* -------------------------------------------------------------------------- */
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(
120deg,
#bd34fe 30%,
#41d1ff
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#bd34fe 50%,
#47caff 50%
);
--vp-home-hero-image-filter: blur(44px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
/**
* Component: Custom Block
* -------------------------------------------------------------------------- */
:root {
--vp-custom-block-tip-border: transparent;
--vp-custom-block-tip-text: var(--vp-c-text-1);
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
}
/**
* Component: Sidebar
* -------------------------------------------------------------------------- */
:root {
--vp-sidebar-bg-color: transparent;
--vp-section-tabs-height: 44px;
}
@media (max-width: 959px) {
:root {
--vp-sidebar-bg-color: var(--vp-c-bg-alt);
}
.VPSidebar {
background-color: var(--vp-c-bg-alt) !important;
}
}
.VPSidebarItem.is-link > .item > .link {
margin: 2px 0;
border-radius: 8px;
padding: 0 10px;
transition: none;
}
.VPSidebarItem,
.VPSidebarItem > .item,
.VPSidebarItem > .item > .link {
border-bottom: none !important;
}
.VPSidebar .group + .group {
border-top: none !important;
}
.VPSidebar {
scrollbar-width: thin;
scrollbar-color: var(--vp-c-divider) transparent;
}
.VPSidebar::-webkit-scrollbar {
width: 10px;
}
.VPSidebar::-webkit-scrollbar-track {
background: transparent;
}
.VPSidebar::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
background-color: var(--vp-c-divider);
}
.VPSidebar::-webkit-scrollbar-thumb:hover {
background-color: var(--vp-c-text-3);
}
.VPSidebarItem.is-link > .item > .link:hover {
background-color: var(--vp-c-default-soft);
}
.VPSidebarItem.is-link.is-active > .item > .link {
background-color: var(--vp-c-brand-soft);
}
/**
* Component: Algolia
* -------------------------------------------------------------------------- */
.DocSearch {
--docsearch-primary-color: var(--vp-c-brand-1) !important;
}
/**
* Component: Nav
* -------------------------------------------------------------------------- */
.VPNavBarTitle .logo {
width: 40px;
height: 40px;
}
.VPNavBarTitle .title > span {
font-size: 26px;
color: var(--vp-c-text-1);
}
@media (min-width: 960px) {
.VPNavBar.has-sidebar .wrapper {
padding: 0 32px !important;
background-color: var(--vp-nav-bg-color) !important;
}
.VPNavBar.has-sidebar .container {
max-width: calc(var(--vp-layout-max-width) - 64px) !important;
justify-content: flex-start !important;
gap: 24px !important;
background-color: var(--vp-nav-bg-color) !important;
}
.VPNavBar.has-sidebar .container > .title {
position: relative !important;
z-index: 3 !important;
padding: 0 !important;
width: auto !important;
max-width: none !important;
background-color: var(--vp-nav-bg-color) !important;
}
.VPNavBar.has-sidebar .content {
padding-left: 0 !important;
padding-right: 0 !important;
}
.VPNavBar.has-sidebar .content-body {
justify-content: flex-start !important;
}
.VPNavBar.has-sidebar .menu {
margin-right: auto !important;
}
.VPNavBar.has-sidebar .divider {
padding-left: 0 !important;
}
.VPNavBar.has-sidebar .VPNavBarTitle .title {
border-bottom: none !important;
background-color: var(--vp-nav-bg-color);
}
}
@media (min-width: 1440px) {
.VPNavBar.has-sidebar .container > .title {
padding-left: 0 !important;
width: auto !important;
}
.VPNavBar.has-sidebar .content {
padding-right: 0 !important;
padding-left: 0 !important;
}
.VPNavBar.has-sidebar .divider {
padding-left: 0 !important;
}
}
/**
* Component: Local Nav
* -------------------------------------------------------------------------- */
@media (min-width: 960px) {
.VPLocalNav.has-sidebar {
border-bottom: none !important;
}
.VPLocalNav.has-sidebar::after {
content: "";
position: absolute;
left: var(--vp-sidebar-width);
right: 0;
bottom: 0;
height: 1px;
background-color: var(--vp-c-gutter);
}
}
@media (min-width: 1440px) {
.VPLocalNav.has-sidebar::after {
left: calc((100vw - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
}
.VPDocAsideOutline.has-outline .content {
border-left: none !important;
}
@media (min-width: 1280px) {
.VPNavBar.has-sidebar .divider {
display: none !important;
}
.VPSidebar {
padding-top: calc(var(--vp-nav-height) + var(--vp-section-tabs-height)) !important;
}
.Layout.sidebar-scope-intro-deploy .VPSidebar .group,
.Layout.sidebar-scope-platform .VPSidebar .group,
.Layout.sidebar-scope-providers .VPSidebar .group,
.Layout.sidebar-scope-use .VPSidebar .group,
.Layout.sidebar-scope-dev .VPSidebar .group {
display: none;
}
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(1),
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(2),
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(7),
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(8),
.Layout.sidebar-scope-platform .VPSidebar .group:nth-of-type(3),
.Layout.sidebar-scope-providers .VPSidebar .group:nth-of-type(4),
.Layout.sidebar-scope-use .VPSidebar .group:nth-of-type(5),
.Layout.sidebar-scope-dev .VPSidebar .group:nth-of-type(6) {
display: block;
}
}
.VPHomeHero:not(.has-image) .container {
text-align: center;
}
.VPHomeHero:not(.has-image) .heading {
align-items: center;
}
.VPHomeHero:not(.has-image) .name,
.VPHomeHero:not(.has-image) .text,
.VPHomeHero:not(.has-image) .tagline {
margin: 0 auto;
}
.VPHomeHero:not(.has-image) .actions {
justify-content: center;
}
+10
View File
@@ -0,0 +1,10 @@
# AstrBot
_✨ 易上手的多平台 LLM 聊天机器人及开发框架(的官方文档) ✨_
[查看文档](https://docs.astrbot.app/) [问题提交](https://github.com/AstrBotDevs/AstrBot/issues)
[AstrBot](https://github.com/AstrBotDevs/AstrBot) 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
![image](https://github.com/user-attachments/assets/48f72a71-9456-4166-bbd2-f2a6c8cd740f)
+32
View File
@@ -0,0 +1,32 @@
# Community
## Community Channels
This documentation may not cover all features comprehensively. If you have any questions or suggestions regarding AstrBot or this documentation, please feel free to reach out to us through the community channels below.
### Discord
<https://discord.gg/PxgzhmxJ>
### GitHub
Welcome to submit Issues or Pull Requests:
- [AstrBotDevs/AstrBot](https://github.com/AstrBotDevs/AstrBot)
### Tencent QQ Groups
> - All groups are available to join. If you find that the group size is below the limit, please feel free to join.
- Group 1: 322154837 (2000-member group)
- Group 3: 630166526 (2000-member group)
- Group 4: 1077826412 (1000-member group)
- Group 5: 822130018 (2000-member group)
- Group 6: 753075035 (2000-member group)
- Group 7: 743746109 (500-member group)
- Group 8: 1030353265 (500-member group)
- **AstrBot Core Development Group: 975206796** (AstrBot development members are usually active here. Welcome to anyone interested in programming/AI technology~)
## Become an AstrBot Organization Member
We welcome you to join us!
+12
View File
@@ -0,0 +1,12 @@
# 配置自定义的模型参数
请手动修改位于 `data/cmd_config.json` 下的配置文件。
找到 `provider`,并找到你想要修改的提供商的模型配置:
![alt text](https://files.astrbot.app/docs/source/images/model-config/image-2.png)
然后在 `model_config` 中添加新的参数即可。
具体的参数请参看对应的提供商的文档。
+27
View File
@@ -0,0 +1,27 @@
# Deploy AstrBot on 1Panel
[1Panel](https://1panel.cn/) is an open-source next-generation Linux server operation and management panel.
AstrBot has been published to the [1Panel App Store](https://apps.fit2cloud.com/1panel) by the 1Panel team, allowing users to quickly deploy and use it directly through 1Panel.
## Install 1Panel
If you haven't installed 1Panel yet, please refer to the [1Panel official website](https://1panel.cn/) for one-click installation.
> International users can refer to the [1Panel official site](https://github.com/1Panel-dev/1Panel) for tutorials.
## Install AstrBot
Open the 1Panel panel, go to the 1Panel App Store, and search for `AstrBot`, as shown below.
![image](https://files.astrbot.app/docs/source/images/1panel/image.png)
Click `Install` and wait for the installation to complete.
After successful installation, open the corresponding AstrBot port (default is 6185) in the 1Panel System-Firewall page.
If you are using cloud servers from providers like AWS, Alibaba Cloud, Tencent Cloud, etc., make sure their security groups also allow port 6185.
## Access AstrBot
Visit `http://IP:6185` to access the AstrBot dashboard.
+48
View File
@@ -0,0 +1,48 @@
# Deploy AstrBot on BT Panel
[BT Panel](https://www.bt.cn/new/index.html) is a secure, efficient, and production-ready Linux/Windows server operation panel.
AstrBot has been published to BT Panel's Docker App Store, supporting one-click installation.
## Install BT Panel
If you haven't installed BT Panel yet, please refer to [Install BT Products](https://www.bt.cn/new/download.html) for one-click installation.
## Set Acceleration URL (For Users in Mainland China)
After entering the BT Panel page, click `Docker` on the left sidebar, click Settings, and modify the `Acceleration URL`.
![alt text](https://files.astrbot.app/docs/source/images/btpanel/image-1.png)
## Install AstrBot
Go to Docker's App Store and search for `AstrBot`, as shown below.
![image](https://files.astrbot.app/docs/source/images/btpanel/image.png)
Click Install and wait for the installation to complete.
After successful installation, click `Security` on the left sidebar and open the corresponding AstrBot port (default is 6185).
If you are using cloud servers from providers like AWS, Alibaba Cloud, Tencent Cloud, etc., make sure their security groups also allow the corresponding port.
## Access AstrBot
Visit `http://IP:6185` to access the AstrBot dashboard.
> [!TIP]
> By default, the above method only opens port 6185. If you need to deploy messaging platforms, you need to additionally open the corresponding ports. Click `Container` in the top bar, find the AstrBot container, click `Manage`, click `Edit Container`, and add the corresponding ports.
>
> ![image](https://files.astrbot.app/docs/source/images/btpanel/image-2.png)
>
> For specific messaging platform port mappings, refer to the table below:
>
>| Port | Description | Type
>| -------- | ------- | ------- |
>| 6185 | AstrBot WebUI `default` port | Required |
>| 6195 | WeCom `default` port | Optional |
>| 6199 | QQ Personal Account(aiocqhttp) `default` port | Optional |
>| 6196 | QQ Official API(Webhook) `default` port | Optional |
>
> Platforms not listed do not require additional port opening.
+39
View File
@@ -0,0 +1,39 @@
# Deploy AstrBot on CasaOS
## Install CasaOS
```bash
curl -fsSL https://get.casaos.io | sudo bash
```
## Add CasaOS-AppStore-Play App Store Source
![image](https://files.astrbot.app/docs/source/images/casaos/image.png)
Click `More Apps`, then enter:
```txt
https://play.cuse.eu.org/Cp0204-AppStore-Play.zip
```
And add it, wait for the addition to complete.
If your network environment is in mainland China, please search for and add `dkTurbo` first, otherwise you may not be able to pull the AstrBot image.
![image](https://files.astrbot.app/docs/source/images/casaos/image-1.png)
Enter `Astrbot` to find AstrBot.
![image](https://files.astrbot.app/docs/source/images/casaos/image-2.png)
Click the icon (not the install button), then hover over the `Install` button and click Custom Install.
![image](https://files.astrbot.app/docs/source/images/casaos/image-3.png)
In the Network section, select `host`.
![image](https://files.astrbot.app/docs/source/images/casaos/image-4.png)
Then click `Install` to start the installation.
After installation is complete, the AstrBot APP will appear on the main interface. Click it to open the dashboard.
+92
View File
@@ -0,0 +1,92 @@
# Deploy AstrBot from Source Code
> [!WARNING]
> You are deploying this project directly from source code. This tutorial requires you to have some technical background.
>
> This tutorial assumes Python is already installed on your device with version `>=3.10`
## Download/Clone Repository
If you have `git` installed on your computer, you can download the source code with the following command:
```bash
git clone https://github.com/AstrBotDevs/AstrBot.git
# The above code will pull the latest commit of the source code, if you need to pull the latest stable release version of the source code, you can use the following command:
# git clone --depth=1 --branch $(git ls-remote --tags --sort='-v:refname' https://github.com/AstrBotDevs/AstrBot.git | head -n1 | awk -F/ '{print $3}') https://github.com/AstrBotDevs/AstrBot.git
cd AstrBot
```
If you don't have `git` installed, please download and install it first.
Alternatively, download the source code directly from GitHub and extract it:
![image](https://files.astrbot.app/docs/source/images/cli/image.png)
## Install Dependencies and Run
::: details 【🥳Recommended】Use `uv` to Manage Dependencies
> If `uv` is not installed, please refer to [Installing uv](https://docs.astral.sh/uv/getting-started/installation/) for installation.
2. Execute in terminal (in the AstrBot directory)
```bash
uv sync
uv run main.py
```
If you have installed some plugins, it is recommended to add the `--no-sync` parameter for subsequent startups to avoid reinstalling plugin dependencies. We are working on solving this issue, so stay tuned.
```bash
uv run --no-sync main.py
```
:::
::: details Install Dependencies with Python Built-in venv
In the AstrBot source code directory, run the following command in the terminal:
> If on Windows and you downloaded and extracted the source code directly, please open the extracted folder and enter in the address bar:
> ![image](https://files.astrbot.app/docs/source/images/cli/image-1.png)
```bash
python3 -m venv ./venv
```
> It might be `python` instead of `python3`
The above steps will create and activate a virtual environment (to avoid disrupting your local Python environment).
Next, install the dependencies with the following command, which may take some time:
Execute on Mac/Linux/WSL:
```bash
source venv/bin/activate
python -m pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
python main.py
```
Execute on Windows:
```bash
venv\Scripts\activate
python -m pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
python main.py
```
:::
## 🎉 All Done!
If everything goes well, you will see logs printed by AstrBot.
If there are no errors, you will see a log message similar to `🌈 Dashboard started, accessible at` with several links. Open one of the links to access the AstrBot dashboard. The link is `http://localhost:6185`.
> [!TIP]
> If you are deploying AstrBot on a server, you need to replace `localhost` with your server's IP address.
>
> The default username and password are `astrbot` and `astrbot`.
Next, you need to deploy any messaging platform to use AstrBot on that platform.
@@ -0,0 +1,52 @@
# Community-Provided Deployment Methods
> [!WARNING]
> AstrBot official does not guarantee the security and stability of these deployment methods.
## Linux One-Click Deployment Script
Use `curl` to download the script and execute it using `bash`:
```bash
bash <(curl -sSL https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh)
```
If your system does not have `curl`, you can use `wget`:
```bash
wget -qO- https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh | bash
```
Repository Address: [zhende1113/Antlia](https://github.com/zhende1113/Antlia/)
## Linux One-Click Deployment Script (Based on Docker)
Supports AstrBot / NapCat.
> [!TIP]
> Use `sudo` for elevated permissions if you have insufficient privileges.
### Using `curl`
```bash
curl -sSL https://raw.githubusercontent.com/railgun19457/AstrbotScript/main/AstrbotScript.sh -o AstrbotScript.sh
chmod +x AstrbotScript.sh
sudo ./AstrbotScript.sh
```
### Using `wget`
```bash
wget -qO AstrbotScript.sh https://raw.githubusercontent.com/railgun19457/AstrbotScript/main/AstrbotScript.sh
chmod +x AstrbotScript.sh
sudo ./AstrbotScript.sh
```
> [!note]
> `sudo ./AstrbotScript.sh --no-color (Optional: disable color output)`
__Repository Address: [railgun19457/AstrbotScript](https://github.com/railgun19457/AstrbotScript)__
## AstrBot Android Deployment
Refer to [zz6zz666/AstrBot-Android-App](https://github.com/zz6zz666/AstrBot-Android-App)
+92
View File
@@ -0,0 +1,92 @@
# Deploy via Compshare
Compshare is UCloud's GPU compute rental and LLM API platform, offering compute resources for AI, deep learning, and scientific workloads.
AstrBot provides an Ollama + AstrBot one-click self-deployment image on Compshare, and also supports Compshare model APIs.
## Use the Ollama + AstrBot One-Click Image
> Default image spec: RTX 3090 24GB + Intel 16-core + 64GB RAM + 200GB system disk. Billing is pay-as-you-go, so please monitor your balance.
1. Register a Compshare account via [this link](https://passport.compshare.cn/register?referral_code=FV7DcGowN4hB5UuXKgpE74).
2. Open the [AstrBot image page](https://www.compshare.cn/images/0oX7xoGrzfre) and create an instance.
3. After deployment, open `JupyterLab` from the [console](https://console.compshare.cn/light-gpu/console/resources).
4. In JupyterLab, create a new terminal and run:
```bash
cd
./astrbot_booter.sh
```
If startup succeeds, you should see output similar to:
```txt
(py312) root@f8396035c96d:/workspace# cd
./astrbot_booter.sh
Starting AstrBot...
Starting ollama...
Both services started in the background.
```
After startup, open `http://<instance-public-ip>:6185` in your browser to access the AstrBot dashboard.
You can find the public IP in Console -> Basic Network (Public).
> It may take around 30 seconds before the page becomes reachable.
![WebUI](https://www-s.ucloud.cn/2025/07/7e9fc6edc1dfa916abc069f4cecc24cf_1753940381771.png)
Login with username `astrbot` and password `astrbot`.
After logging in, you can reset your password and continue setup.
The instance imports `Ollama-DeepSeek-R1-32B` by default.
## Use Other Models
### Pull Models with Ollama
The image includes Ollama. You can pull any model and host it locally on the instance.
1. Choose a model from [Ollama Search](https://ollama.com/search).
2. Connect to the instance terminal via SSH (from Compshare Console -> Instance List -> Console Command and Password).
3. Run `ollama pull <model-name>` and wait for completion.
4. In AstrBot Dashboard -> Providers, edit `ollama_deepseek-r1`, update the model name, and save.
![image](https://files.astrbot.app/docs/source/images/compshare/image-1.png)
### Use Compshare Model API
AstrBot supports direct access to model APIs provided by Compshare.
1. Find the model you want at [Compshare Model Center](https://console.compshare.cn/light-gpu/model-center).
2. In AstrBot Dashboard -> Providers, click `+ Add Provider`, then choose Compshare.
If Compshare is not listed, choose OpenAI-compatible access and set API Base URL to `https://api.modelverse.cn/v1`.
Enter the model name in model configuration and save.
### Test
In AstrBot Dashboard, click `Chat` and run `/provider` to view and switch your active provider.
Then send a normal message to test whether the model works.
![image](https://files.astrbot.app/docs/source/images/compshare/image-2.png)
## Connect to Messaging Platforms
You can follow the latest platform integration guides in the [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html).
Open the docs and check the left sidebar under Messaging Platforms.
- Lark: [Connect to Lark](https://docs.astrbot.app/en/platform/lark.html)
- LINE: [Connect to LINE](https://docs.astrbot.app/en/platform/line.html)
- DingTalk: [Connect to DingTalk](https://docs.astrbot.app/en/platform/dingtalk.html)
- WeCom: [Connect to WeCom](https://docs.astrbot.app/en/platform/wecom.html)
- WeChat Official Account: [Connect to WeChat Official Account](https://docs.astrbot.app/en/platform/weixin-official-account.html)
- QQ Official Bot: [Connect to QQ Official API](https://docs.astrbot.app/en/platform/qqofficial/webhook.html)
- KOOK: [Connect to KOOK](https://docs.astrbot.app/en/platform/kook.html)
- Slack: [Connect to Slack](https://docs.astrbot.app/en/platform/slack.html)
- Discord: [Connect to Discord](https://docs.astrbot.app/en/platform/discord.html)
- More methods: [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html)
## More Features
For more capabilities, see the [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html).
+91
View File
@@ -0,0 +1,91 @@
# Deploy AstrBot with Docker
> [!WARNING]
> Docker provides a convenient way to deploy AstrBot on Windows, Mac, and Linux.
>
> This tutorial assumes you have Docker installed in your environment. If not, please refer to the [Docker official documentation](https://docs.docker.com/get-docker/) for installation.
## Deploy with Docker Compose
::: details Deploy AstrBot Only (General Method)
First, clone the AstrBot repository to your local machine:
```bash
git clone https://github.com/AstrBotDevs/AstrBot
cd AstrBot
```
Then, run Compose:
```bash
sudo docker compose up -d
```
> [!TIP]
> If your network environment is in mainland China, the above command will not pull properly. You may need to modify the compose.yml file and replace `image: soulter/astrbot:latest` with `image: m.daocloud.io/docker.io/soulter/astrbot:latest`.
:::
::: details Deploy with Agent Sandbox Environment
Supports native Python code execution, Shell code execution, and other features.
Deployment method:
```bash
git clone https://github.com/AstrBotDevs/AstrBot
cd AstrBot
# Modify the environment variable configuration in the compose-with-shipyard.yml file, such as Shipyard's access token, etc.
docker compose -f compose-with-shipyard.yml up -d
docker pull soulter/shipyard-ship:latest
```
For configuration and usage details, see the [Agent Sandbox Environment](/en/use/astrbot-agent-sandbox.md) documentation.
:::
## Deploy with Docker
```bash
mkdir astrbot
cd astrbot
sudo docker run -itd -p 6185:6185 -p 6199:6199 -v $PWD/data:/AstrBot/data -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro --name astrbot soulter/astrbot:latest
```
> [!TIP]
> If your network environment is in mainland China, the above command will not pull properly. Please use the following command to pull the image:
>
> ```bash
> sudo docker run -itd -p 6185:6185 -p 6199:6199 -v $PWD/data:/AstrBot/data -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
> ```
>
> (Thanks to DaoCloud ❤️)
> No need to add sudo on Windows, same below
> Sync Host Time on Windows (requires WSL2)
```
-v \\wsl.localhost\(your-wsl-os)\etc\timezone:/etc/timezone:ro
-v \\wsl.localhost\(your-wsl-os)\etc\localtime:/etc/localtime:ro
```
View AstrBot logs with the following command:
```bash
sudo docker logs -f astrbot
```
## 🎉 All Done
If everything goes well, you will see logs printed by AstrBot.
If there are no errors, you will see a log message similar to `🌈 Dashboard started, accessible at` with several links. Open one of the links to access the AstrBot dashboard.
> [!TIP]
> Since Docker isolates the network environment, you cannot use `localhost` to access the dashboard.
>
> The default username and password are `astrbot` and `astrbot`.
>
> If deployed on a cloud server, you need to open ports `6180-6200` and `11451` in the cloud provider's console.
Next, you need to deploy any messaging platform to use AstrBot on that platform.
+197
View File
@@ -0,0 +1,197 @@
# Deploy AstrBot with Kubernetes
> [!WARNING]
> You can deploy AstrBot in a high-availability setup using Kubernetes (K8s), allowing it to automatically recover from failures.
>
> Due to the current use of an SQLite database, this deployment does not support horizontal scaling with multiple replicas. Additionally, if using the Sidecar mode, pay special attention to the persistence of NapCat's login state.
>
> The following tutorial assumes that you have `kubectl` installed and configured, and that you can connect to your K8s cluster.
## Prerequisites
Before you begin, make sure your Kubernetes cluster meets the following conditions:
1. **Default StorageClass**: Used to dynamically create `PersistentVolumeClaim` (PVC). You can check this with `kubectl get sc`. If you don't have one, you need to manually create a `PersistentVolume` (PV) or install a corresponding storage plugin (e.g., `nfs-client-provisioner`).
2. **Network Access**: Ensure that your cluster nodes can pull images from `docker.io` or your specified image repository.
## Deployment Methods
We offer two deployment options:
* **Integrated Deployment (Sidecar Mode)**: Deploy AstrBot and NapCat in the same Pod. Recommended for personal QQ accounts.
* **Standalone Deployment**: Deploy only AstrBot. Suitable for other platforms or if you want to manage NapCat independently.
---
### Method 1: Deploy with NapCatQQ (Sidecar)
This method is located in the `k8s/astrbot_with_napcat` directory.
#### 1. Deploy
```bash
# 1. Create namespace
kubectl apply -f k8s/astrbot_with_napcat/00-namespace.yaml
# 2. Create Persistent Volume Claim
# Note: astrbot-data-shared-pvc requires ReadWriteMany (RWX) access mode.
# If your cluster does not support RWX, you need to configure shared storage such as NFS and modify the storageClassName in 01-pvc.yaml.
kubectl apply -f k8s/astrbot_with_napcat/01-pvc.yaml
# 3. Deploy the application
kubectl apply -f k8s/astrbot_with_napcat/02-deployment.yaml
```
#### 2. Expose Service (Choose one)
* **Option A: NodePort**
```bash
kubectl apply -f k8s/astrbot_with_napcat/03-service-nodeport.yaml
```
The service will be exposed via the node IP and a port automatically assigned by Kubernetes. You can find the port with the following command:
```bash
kubectl get svc -n astrbot-ns
```
In the output, find the `PORT(S)` column for `astrbot-webui-svc` and `napcat-web-svc`. The format is `<internal-port>:<NodePort>/TCP`. For example, if you see `8080:30185/TCP`, the access address is `http://<NodeIP>:30185`.
* **Option B: LoadBalancer**
If your cluster supports `LoadBalancer` type services (usually provided in K8s services from cloud providers), you can use this method.
```bash
kubectl apply -f k8s/astrbot_with_napcat/04-service-loadbalancer.yaml
```
After execution, check the assigned external IP (EXTERNAL-IP) with `kubectl get svc -n astrbot-ns`.
#### 3. Configure Connection
Since AstrBot and NapCat are in the same Pod, they can communicate directly via `localhost`.
1. **Add a message platform in AstrBot:**
* Go to the AstrBot WebUI, select `Settings` -> `Message Platform` -> `Add`.
* **Select Message Platform Category**: `aiocqhttp`
* **Bot Name**: `napcat` (or custom)
* **Reverse Websocket Host**: `0.0.0.0`
* **Reverse Websocket Port**: `6199`
* Save the configuration.
2. **Configure Websocket Client in NapCat:**
* Go to the NapCat WebUI, select `Settings` -> `Reverse WS` -> `Add`.
* **Enable**: On
* **URL**: `ws://localhost:6199/ws`
* **Message Format**: `Array`
* Save the configuration.
---
### Method 2: Deploy AstrBot Only (General Purpose)
This method is located in the `k8s/astrbot` directory.
#### 1. Deploy
```bash
# 1. Create namespace
kubectl apply -f k8s/astrbot/00-namespace.yaml
# 2. Create Persistent Volume Claim
kubectl apply -f k8s/astrbot/01-pvc.yaml
# 3. Deploy the application
kubectl apply -f k8s/astrbot/02-deployment.yaml
```
#### 2. Expose Service (Choose one)
* **Option A: NodePort**
```bash
kubectl apply -f k8s/astrbot/03-service-nodeport.yaml
```
The service will be exposed via the node IP and a port automatically assigned by Kubernetes. You can find the port with the following command:
```bash
kubectl get svc -n astrbot-standalone-ns
```
In the output, find the `PORT(S)` column for `astrbot-webui-svc`. The format is `<internal-port>:<NodePort>/TCP`. For example, if you see `8080:30185/TCP`, the access address is `http://<NodeIP>:30185`.
* **Option B: LoadBalancer**
```bash
kubectl apply -f k8s/astrbot/04-service-loadbalancer.yaml
```
After execution, check the assigned external IP (EXTERNAL-IP) with `kubectl get svc -n astrbot-standalone-ns`.
---
## Advanced Configuration
### Image Mirror (for users in mainland China)
If you have difficulty pulling the `soulter/astrbot:latest` or `mlikiowa/napcat-docker:latest` images, you can manually edit the corresponding `02-deployment.yaml` file and replace the `image` field with a domestic mirror address, for example:
```yaml
# Example:
# image: soulter/astrbot:latest
# Replace with:
image: m.daocloud.io/docker.io/soulter/astrbot:latest
```
### Enable Docker Sandbox Code Executor
If you need to use the sandbox code executor, you need to mount the Docker socket file into the Pod.
Edit the `02-deployment.yaml` file and add `volumes` and `volumeMounts` under `spec.template.spec`:
1. **Add the following to the `volumeMounts` list of the `astrbot` container:**
```yaml
- name: docker-sock
mountPath: /var/run/docker.sock
```
2. **Add the following to the `spec.template.spec.volumes` list:**
```yaml
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: Socket
```
> [!WARNING]
> Mounting the Docker socket into a Pod poses a security risk. Please ensure you understand the implications.
## View Logs
* **Sidecar Deployment Mode:**
```bash
# View AstrBot logs
kubectl logs -f -n astrbot-ns deployment/astrbot-stack -c astrbot
# View NapCat logs
kubectl logs -f -n astrbot-ns deployment/astrbot-stack -c napcat
```
* **Standalone Deployment Mode:**
```bash
kubectl logs -f -n astrbot-standalone-ns deployment/astrbot-standalone
```
## 🎉 All Done!
After deploying and exposing the service, you can access the AstrBot admin panel through the corresponding IP and port.
> The default username and password are `astrbot` and `astrbot`.
+98
View File
@@ -0,0 +1,98 @@
# Deploy AstrBot with AstrBot Launcher
## Recommended Method 1: AstrBot One-Click Launcher
AstrBot One-Click Launcher supports Windows, macOS, and Linux.
0. Open [AstrBotDevs/astrbot-launcher](https://github.com/AstrBotDevs/astrbot-launcher)
1. **Optional but recommended**: give this project a [**Star ⭐**](https://github.com/AstrBotDevs/astrbot-launcher). Your support helps maintainers keep improving it.
2. Find **Releases** on the right, open the latest release, then download the installer for your system from **Assets**.
For example:
- Windows x86 users: `AstrBot.Launcher_0.2.1_x64-setup.exe`
- Windows on Arm users: `AstrBot.Launcher_0.2.1_arm64-setup.exe`
- macOS Apple Silicon users: `AstrBot.Launcher_0.2.1_aarch64.dmg`
For macOS users, if you see "damaged and can't be opened", it is caused by macOS security restrictions on unsigned apps. Fix it with:
1. Open Terminal.
2. Run:
`xattr -dr com.apple.quarantine /Applications/AstrBot\ Launcher.app`
3. Reopen AstrBot Launcher.
## Method 2: Legacy Windows Installer
We still recommend the One-Click Launcher above because it is simpler, more automated, and better for most users.
The legacy installer is a `PowerShell` script, very small (<20KB). It requires `PowerShell` (usually built in on `Windows 10` and newer).
> [!WARNING]
> `Python 3.10` or later must be installed, and environment variables must be configured.
> [!TIP]
> If deployment fails, try Docker deployment or manual deployment instead.
## Download the Legacy Installer
Open <https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest>
Download `Source code (zip)` and extract it.
## Run the Legacy Installer
> The video may be outdated. Follow the steps here.
After extraction, open the folder.
Type `PowerShell` in the address bar and press Enter:
![image](https://files.astrbot.app/docs/source/images/windows/image-4.png)
Drag `launcher_astrbot_en.bat` into the PowerShell window and press Enter.
> [!WARNING]
> - The script is safe. If you see `Windows protected your PC`, click `More info` and then `Run anyway`.
> - By default, it uses `python`. If you want to specify another interpreter path/command, edit `launcher_astrbot_en.bat`, find `set PYTHON_CMD=python`, and replace `python` with your own command/path.
If Python is not detected, the script exits with a prompt.
The script checks whether an `AstrBot` folder exists. If not, it downloads the latest AstrBot source from [GitHub](https://github.com/AstrBotDevs/AstrBot/releases/latest), installs dependencies, and runs it automatically.
## Done
If everything works, you will see AstrBot logs.
Without errors, you should see a log like `🌈 Management panel started, accessible at` with several URLs. Open one URL to access AstrBot WebUI.
> [!TIP]
> Default username and password: `astrbot` / `astrbot`.
>
> If WebUI returns 404:
> Download `dist.zip` from [release](https://github.com/AstrBotDevs/AstrBot/releases), extract it into `AstrBot/data`, then restart the computer if needed.
Then deploy at least one messaging platform adapter to start using AstrBot in IM apps.
## Error: Python is not installed
If you still get this error after installing Python and restarting, your PATH is likely incorrect.
**Method 1**
Search for Python in Windows and open its file location:
![image](https://files.astrbot.app/docs/source/images/windows/image.png)
Right-click the shortcut below and open file location:
![alt text](https://files.astrbot.app/docs/source/images/windows/image-1.png)
Copy the file path:
![image](https://files.astrbot.app/docs/source/images/windows/image-2.png)
Edit `launcher_astrbot_en.bat` in Notepad, find `set PYTHON_CMD=python`, and replace `python` with your interpreter command/path. Keep quotes if your path contains spaces.
**Method 2**
Reinstall Python, check `Add Python to PATH` during installation, then restart your computer.
@@ -0,0 +1,5 @@
# Other Deployments
- [CasaOS Deployment](./casaos.md)
- [Compshare GPU Deployment](./compshare.md)
- [Community Deployments](./community-deployment.md)
+17
View File
@@ -0,0 +1,17 @@
# Package Manager Deployment (uv)
Use `uv` to install and run AstrBot quickly.
## Before You Start
If `uv` is not installed, install it first by following the official guide:
<https://docs.astral.sh/uv/>
`uv` supports Linux, Windows, and macOS.
## Install and Start
```bash
uv tool install astrbot
astrbot
```
+41
View File
@@ -0,0 +1,41 @@
# Installation via System Package Manager
> [!WARNING]
> Currently, only the AUR version is provided.
> If you are a Windows/macOS user, it is recommended to install via `uv`.
> If you are a Linux user, it is highly recommended to install via a package manager.
# Preparation
## What is AUR?
AUR (Arch User Repository) allows users to install software from community-maintained software repositories. AUR packages are typically maintained by community members rather than official maintainers.
Common AUR helpers include `yay` and `paru`.
The following tutorial uses `paru` as an example; `yay` works similarly, just replace `paru` with `yay`.
# Installation Process
## AUR
```bash
paru -S astrbot-git
# Note:
# The review step will begin; press 'q' to exit review and continue installation.
# After installation, the data directory is fixed at: ~/.local/share/astrbot
```
# Starting
>[!TIP]
> You can directly use `astrbot init` (for the first run) to initialize.
> Use `astrbot run` to run the bot.
> However, it is highly recommended to use `systemctl` for starting, as it provides features like automatic restart and log rotation.
```bash
systemctl --user start astrbot.service
```
# Auto-start on Boot
```bash
# For security reasons, it is designed to run as a user.
systemctl --user enable astrbot.service
# If you need to start it immediately, add --now
# systemctl --user enable --now astrbot.service
```
+16
View File
@@ -0,0 +1,16 @@
# Preface
After successful deployment... of course, don't forget to give [AstrBot](https://github.com/AstrBotDevs/AstrBot) a Star!
AstrBot Main Repository: [![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
AstrBot Dashboard: [![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c440f-c177-45f8-8224-292cdf5926f3.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c440f-c177-45f8-8224-292cdf5926f3)
AstrBot Documentation: [![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c9619-e195-4b94-bd7b-2ca61679145b.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c9619-e195-4b94-bd7b-2ca61679145b)
❤️ Contributions to this project are warmly welcomed, including Issues and Pull Requests.
## Next...
If you're reading this, it means you have successfully deployed the messaging platform and sent/received your first command. Next, you can configure large language models or add plugins. Please refer to the `Configuration - Integrating LLM Services` section.
+565
View File
@@ -0,0 +1,565 @@
---
outline: deep
---
# AstrBot Configuration File
## data/cmd_config.json
AstrBot's configuration file is a JSON format file. AstrBot reads this file at startup and initializes based on the settings within. Its path is `data/cmd_config.json`.
> Since AstrBot v4.0.0, we introduced the concept of [multiple configuration files](https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6). `data/cmd_config.json` serves as the default configuration `default`. Other configuration files you create in the WebUI are stored in the `data/config/` directory, starting with `abconf_`.
The default AstrBot configuration is as follows:
```jsonc
{
"config_version": 2,
"platform_settings": {
"unique_session": False,
"rate_limit": {
"time": 60,
"count": 30,
"strategy": "stall", # stall, discard
},
"reply_prefix": "",
"forward_threshold": 1500,
"enable_id_white_list": True,
"id_whitelist": [],
"id_whitelist_log": True,
"wl_ignore_admin_on_group": True,
"wl_ignore_admin_on_friend": True,
"reply_with_mention": False,
"reply_with_quote": False,
"path_mapping": [],
"segmented_reply": {
"enable": False,
"only_llm_result": True,
"interval_method": "random",
"interval": "1.5,3.5",
"log_base": 2.6,
"words_count_threshold": 150,
"regex": ".*?[。?!~…]+|.+$",
"content_cleanup_rule": "",
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"empty_mention_waiting_need_reply": True,
"friend_message_needs_wake_prefix": False,
"ignore_bot_self_message": False,
"ignore_at_all": False,
},
"provider": [],
"provider_settings": {
"enable": True,
"default_provider_id": "",
"default_image_caption_provider_id": "",
"image_caption_prompt": "Please describe the image using Chinese.",
"provider_pool": ["*"], # "*" means use all available providers
"wake_prefix": "",
"web_search": False,
"websearch_provider": "default",
"websearch_tavily_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
"group_name_display": False,
"datetime_system_prompt": True,
"default_personality": "default",
"persona_pool": ["*"],
"prompt_prefix": "{{prompt}}",
"max_context_length": -1,
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"streaming_segmented": False,
"max_agent_step": 30,
"tool_call_timeout": 60,
},
"provider_stt_settings": {
"enable": False,
"provider_id": "",
},
"provider_tts_settings": {
"enable": False,
"provider_id": "",
"dual_output": False,
"use_file_service": False,
},
"provider_ltm_settings": {
"group_icl_enable": False,
"group_message_max_cnt": 300,
"image_caption": False,
"active_reply": {
"enable": False,
"method": "possibility_reply",
"possibility_reply": 0.1,
"whitelist": [],
},
},
"content_safety": {
"also_use_in_response": False,
"internal_keywords": {"enable": True, "extra_keywords": []},
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
},
"admins_id": ["astrbot"],
"t2i": False,
"t2i_word_threshold": 150,
"t2i_strategy": "remote",
"t2i_endpoint": "",
"t2i_use_file_service": False,
"t2i_active_template": "base",
"http_proxy": "",
"no_proxy": ["localhost", "127.0.0.1", "::1"],
"dashboard": {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
},
"platform": [],
"platform_specific": {
# Platform-specific settings: categorized by platform, then by feature group
"lark": {
"pre_ack_emoji": {"enable": False, "emojis": ["Typing"]},
},
"telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
},
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
},
"wake_prefix": ["/"],
"log_level": "INFO",
"trace_enable": False,
"pip_install_arg": "",
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
"persona": [], # deprecated
"timezone": "Asia/Shanghai",
"callback_api_base": "",
"default_kb_collection": "", # Default knowledge base name
"plugin_set": ["*"], # "*" means use all available plugins, empty list means none
}
```
## Field Details
### `config_version`
Configuration version, do not modify.
### `platform_settings`
General settings for message platform adapters.
#### `platform_settings.unique_session`
Whether to enable session isolation. Default is `false`. When enabled, each person's conversation context in groups or channels is independent.
#### `platform_settings.rate_limit`
Strategy when message rate exceeds limits. `time` is the window, `count` is the number of messages, and `strategy` is the limit strategy. `stall` means wait, `discard` means drop.
#### `platform_settings.reply_prefix`
Fixed prefix string when replying to messages. Default is empty.
#### `platform_settings.forward_threshold`
> Currently only applicable to the QQ platform adapter.
Message forwarding threshold. When the reply content exceeds a certain number of characters, the bot will fold the message into a QQ group "forwarded message" to prevent spamming.
#### `platform_settings.enable_id_white_list`
Whether to enable the ID whitelist. Default is `true`. When enabled, only messages from IDs in the whitelist will be processed.
#### `platform_settings.id_whitelist`
ID whitelist. If filled, only message events from the specified IDs will be processed. Empty means the whitelist filter is not enabled. You can use the `/sid` command to get the session ID on a platform.
Session IDs can also be found in AstrBot logs; when a message fails the whitelist, an INFO level log is output, e.g., `aiocqhttp:GroupMessage:547540978`.
#### `platform_settings.id_whitelist_log`
Whether to print logs for messages that fail the ID whitelist. Default is `true`.
#### `platform_settings.wl_ignore_admin_on_group` & `platform_settings.wl_ignore_admin_on_friend`
- `wl_ignore_admin_on_group`: Whether group messages from admins bypass the ID whitelist. Default is `true`.
- `wl_ignore_admin_on_friend`: Whether private messages from admins bypass the ID whitelist. Default is `true`.
#### `platform_settings.reply_with_mention`
Whether to @ mention the user when replying. Default is `false`.
#### `platform_settings.reply_with_quote`
Whether to quote the user's message when replying. Default is `false`.
#### `platform_settings.path_mapping`
*This configuration item has been deprecated since v4.0.0.*
List of path mappings. Used to replace file paths in messages. Each mapping item contains `from` and `to` fields, indicating that `from` in the message path is replaced with `to`.
#### `platform_settings.segmented_reply`
Segmented reply settings.
- `enable`: Whether to enable segmented replies. Default is `false`.
- `only_llm_result`: Whether to only segment replies generated by the LLM. Default is `true`.
- `interval_method`: Method for segmentation intervals. Options are `random` and `log`. Default is `random`.
- `interval`: Interval time for segmentation. For `random`, fill in two comma-separated numbers representing min and max intervals (seconds). For `log`, fill in one number representing the log base. Default is `"1.5,3.5"`.
- `log_base`: Log base, only applicable when `interval_method` is `log`. Default is `2.6`.
- `words_count_threshold`: Character limit for segmented replies. Only messages shorter than this value will be segmented; longer messages will be sent directly (unsegmented). Default is `150`.
- `regex`: Used to split a message. By default, it splits based on punctuation like periods and question marks. `re.findall(r'<regex>', text)`. Default is `".*?[。?!~…]+|.+$"`.
- `content_cleanup_rule`: Removes specified content from segments. Supports regex. For example, `[。?!]` will remove all periods, question marks, and exclamation points. `re.sub(r'<regex>', '', text)`.
#### `platform_settings.no_permission_reply`
Whether to reply with a "no permission" prompt when a user lacks authority. Default is `true`.
#### `platform_settings.empty_mention_waiting`
Whether to enable the empty @ waiting mechanism. Default is `true`. When enabled, if a user sends a message containing only an @ mention of the bot, the bot waits for the user to send the next message within 60 seconds and merges the two for processing. This is particularly useful on platforms that don't support sending @ and voice/images simultaneously.
#### `platform_settings.empty_mention_waiting_need_reply`
In the above item (`empty_mention_waiting`), if waiting is triggered, enabling this will make the bot immediately generate an LLM reply. Otherwise, it just waits without replying. Default is `true`.
#### `platform_settings.friend_message_needs_wake_prefix`
Whether private messages on platforms require a wake prefix. Default is `false`. When enabled, users must use a wake prefix to trigger a bot response in private chats.
#### `platform_settings.ignore_bot_self_message`
Whether to ignore messages sent by the bot itself. Default is `false`. When enabled, the bot won't process its own messages, preventing infinite loops on some platforms.
#### `platform_settings.ignore_at_all`
Whether to ignore @all messages. Default is `false`. When enabled, the bot won't respond to messages containing @all.
### `provider`
> This item only takes effect in `data/cmd_config.json`; AstrBot does not read this from configuration files in the `data/config/` directory.
List of configured model service provider settings.
### `provider_settings`
General settings for LLM providers.
#### `provider_settings.enable`
Whether to enable LLM chat. Default is `true`.
#### `provider_settings.default_provider_id`
Default conversation model provider ID. Must be a provider ID already configured in the `provider` list. If empty, the first provider in the list is used.
#### `provider_settings.default_image_caption_provider_id`
Default image captioning model provider ID. Must be a provider ID already configured in the `provider` list. If empty, image captioning is disabled.
This means when a user sends an image, AstrBot uses this provider to generate a text description, which is then used as part of the conversation context. This is useful when the conversation model doesn't support multimodal input.
#### `provider_settings.image_caption_prompt`
Prompt template for image captioning. Default is `"Please describe the image using Chinese."`.
#### `provider_settings.provider_pool`
*This configuration item is not yet in actual use.*
#### `provider_settings.wake_prefix`
Extra trigger condition for LLM chat. For example, if `chat` is filled, messages must start with `/chat` to trigger LLM chat, where `/` is the bot's wake prefix. This is a measure to prevent abuse.
#### `provider_settings.web_search`
Whether to enable AstrBot's built-in web search capability. Default is `false`. When enabled, the LLM may automatically search the web and answer based on the content.
#### `provider_settings.websearch_provider`
Web search provider type. Default is `default`. Currently supports `default` and `tavily`.
- `default`: Works best when Google is accessible. If Google fails, it tries Bing and Sogou in order.
- `tavily`: Uses the Tavily search engine.
#### `provider_settings.websearch_tavily_key`
API Key list for the Tavily search engine. Required when using `tavily` as the web search provider.
#### `provider_settings.web_search_link`
Whether to prompt the model to include links to search results in the reply. Default is `false`.
#### `provider_settings.display_reasoning_text`
Whether to display the model's reasoning process in the reply. Default is `false`.
#### `provider_settings.identifier`
Whether to prepend the group member's name to the prompt so the model better understands the group chat state. Default is `false`. Enabling this slightly increases token usage.
#### `provider_settings.group_name_display`
Whether to let the model know the name of the group it's in. Default is `false`. This currently only takes effect in the QQ platform adapter.
#### `provider_settings.datetime_system_prompt`
Whether to include the current machine date and time in the system prompt. Default is `true`.
#### `provider_settings.default_personality`
ID of the default personality to use. Configure personalities in the WebUI.
#### `provider_settings.persona_pool`
*This configuration item is not yet in actual use.*
#### `provider_settings.prompt_prefix`
User prompt. You can use `{{prompt}}` as a placeholder for user input. If no placeholder is provided, it's prepended to the user input.
#### `provider_settings.max_context_length`
When the conversation context exceeds this number, the oldest parts are discarded. One round of chat counts as 1. -1 means no limit.
#### `provider_settings.dequeue_context_length`
The number of conversation rounds to discard each time the `max_context_length` limit is triggered.
#### `provider_settings.streaming_response`
Whether to enable streaming responses. Default is `false`. When enabled, the model's reply is sent to the user in real-time with a typewriter effect. This only takes effect on WebChat, Telegram, and Lark platforms.
#### `provider_settings.show_tool_use_status`
Whether to show tool usage status. Default is `false`. When enabled, the model displays the tool name and input parameters when using a tool.
#### `provider_settings.streaming_segmented`
Whether platforms that don't support streaming responses should fall back to segmented replies. Default is `false`. This means if streaming is enabled but the platform doesn't support it, segmented multiple replies are used instead.
#### `provider_settings.max_agent_step`
Limit on the maximum number of Agent steps. Default is `30`. Each tool call by the model counts as one step.
#### `provider_settings.tool_call_timeout`
Added in `v4.3.5`
Maximum timeout for tool calls (seconds), default is `60` seconds.
#### `provider_stt_settings`
General settings for Speech-to-Text (STT) providers.
#### `provider_stt_settings.enable`
Whether to enable STT services. Default is `false`.
#### `provider_stt_settings.provider_id`
STT provider ID. Must be an STT provider ID already configured in the `provider` list.
#### `provider_tts_settings`
General settings for Text-to-Speech (TTS) providers.
#### `provider_tts_settings.enable`
Whether to enable TTS services. Default is `false`.
#### `provider_tts_settings.provider_id`
TTS provider ID. Must be a TTS provider ID already configured in the `provider` list.
#### `provider_tts_settings.dual_output`
Whether to enable dual output. Default is `false`. When enabled, the bot sends both text and voice messages.
#### `provider_tts_settings.use_file_service`
Whether to enable the file service. Default is `false`. When enabled, the bot provides the output voice file as an external HTTP link to the message platform. This depends on the `callback_api_base` configuration.
#### `provider_ltm_settings`
General settings for group chat context awareness providers.
#### `provider_ltm_settings.group_icl_enable`
Whether to enable group chat context awareness. Default is `false`. When enabled, the bot records group chat conversations to better understand context.
The context content is placed in the conversation's system prompt.
#### `provider_ltm_settings.group_message_max_cnt`
Maximum number of group chat messages to record. Default is `100`. Messages exceeding this count are discarded.
#### `provider_ltm_settings.image_caption`
Whether to record images in group chats and automatically generate text descriptions using an image captioning model. Default is `false`. This depends on the `provider_settings.default_image_caption_provider_id` configuration. Use with caution as it can significantly increase API calls and token usage.
#### `provider_ltm_settings.active_reply`
- `enable`: Whether to enable active replies. Default is `false`.
- `method`: Method for active replies. Option is `possibility_reply`.
- `possibility_reply`: Probability of an active reply. Default is `0.1`. Only applicable when `method` is `possibility_reply`.
- `whitelist`: ID whitelist for active replies. Only IDs in this list will trigger active replies. Empty means no whitelist filter. You can use the `/sid` command to get the session ID on a platform.
### `content_safety`
Content safety settings.
#### `content_safety.also_use_in_response`
Whether to also perform content safety checks on LLM replies. Default is `false`. When enabled, bot-generated replies also undergo safety checks to prevent inappropriate content.
#### `content_safety.internal_keywords`
Internal keyword detection settings.
- `enable`: Whether to enable internal keyword detection. Default is `true`.
- `extra_keywords`: List of extra keywords, supports regex. Default is empty.
#### `content_safety.baidu_aip`
Baidu AI content moderation settings.
- `enable`: Whether to enable Baidu AI content moderation. Default is `false`.
- `app_id`: App ID for Baidu AI content moderation.
- `api_key`: API Key for Baidu AI content moderation.
- `secret_key`: Secret Key for Baidu AI content moderation.
> [!TIP]
> To enable Baidu AI content moderation, please `pip install baidu-aip` first.
### `admins_id`
List of administrator IDs. Additionally, you can use `/op` and `/deop` commands to add or remove admins.
### `t2i`
Whether to enable Text-to-Image (T2I) functionality. Default is `false`. When enabled, if a user's message exceeds a certain character count, the bot renders the message as an image to improve readability and prevent spamming. Supports Markdown rendering.
### `t2i_word_threshold`
Character threshold for T2I. Default is `150`. When a message exceeds this count, the bot renders it as an image.
### `t2i_strategy`
Rendering strategy for T2I. Options are `local` and `remote`. Default is `remote`.
- `local`: Uses AstrBot's local T2I service for rendering. Lower quality but doesn't depend on external services.
- `remote`: Uses a remote T2I service for rendering. Uses the official AstrBot service by default, which offers better quality.
### `t2i_endpoint`
AstrBot API address. Used for rendering Markdown images. Effective when `t2i_strategy` is `remote`. Default is empty, meaning the official AstrBot service is used.
### `t2i_use_file_service`
Whether to enable the file service. Default is `false`. When enabled, the bot provides the rendered image as an external HTTP link to the message platform. This depends on the `callback_api_base` configuration.
### `http_proxy`
HTTP proxy. E.g., `http://localhost:7890`.
### `no_proxy`
List of addresses that bypass the proxy. E.g., `["localhost", "127.0.0.1"]`.
### `dashboard`
AstrBot WebUI configuration.
Please do not change the `password` value arbitrarily. It is an `md5` encoded password. Change the password in the control panel.
- `enable`: Whether to enable the AstrBot WebUI. Default is `true`.
- `username`: Username for the AstrBot WebUI. Default is `astrbot`.
- `password`: Password for the AstrBot WebUI. Default is the `md5` encoded value of `astrbot`. Do not modify directly unless you know what you are doing.
- `jwt_secret`: JWT secret key. AstrBot generates this randomly at initialization. Do not modify unless you know what you are doing.
- `host`: Address the AstrBot WebUI listens on. Default is `0.0.0.0`.
- `port`: Port the AstrBot WebUI listens on. Default is `6185`.
### `platform`
> This item only takes effect in `data/cmd_config.json`; AstrBot does not read this from configuration files in the `data/config/` directory.
List of configured AstrBot message platform adapter settings.
### `platform_specific`
Platform-specific settings. Categorized by platform, then by feature group.
#### `platform_specific.<platform>.pre_ack_emoji`
When enabled, AstrBot sends a pre-reply emoji before requesting the LLM to inform the user that the request is being processed. This currently only takes effect in the Lark and Telegram platform adapters.
##### lark
- `enable`: Whether to enable pre-reply emojis for Lark messages. Default is `false`.
- `emojis`: List of pre-reply emojis. Default is `["Typing"]`. Refer to [Emoji Documentation](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce) for emoji names.
##### telegram
- `enable`: Whether to enable pre-reply emojis for Telegram messages. Default is `false`.
- `emojis`: List of pre-reply emojis. Default is `["✍️"]`. Telegram only supports a fixed set of reactions; refer to [reactions.txt](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9).
##### discord
- `enable`: Whether to enable pre-reply emojis for Discord messages. Default is `false`.
- `emojis`: List of pre-reply emojis. Default is `["🤔"]`. Refer to [Discord Reaction FAQ](https://support.discord.com/hc/en-us/articles/12102061808663-Reactions-and-Super-Reactions-FAQ).
### `wake_prefix`
Wake prefix. Default is `/`. When a message starts with `/`, AstrBot is awakened.
> [!TIP]
> If the awakened session is not in the ID whitelist, AstrBot will not respond.
### `log_level`
Log level. Default is `INFO`. Can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`.
### `trace_enable`
Whether to enable trace recording. Default is `false`. When enabled, AstrBot records execution traces, which can be viewed on the Trace page of the admin panel.
### `pip_install_arg`
Arguments for `pip install`. E.g., `-i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple`.
### `pypi_index_url`
PyPI index URL. Default is `https://mirrors.aliyun.com/pypi/simple/`.
### `persona`
*This configuration item has been deprecated since v4.0.0. Please use the WebUI to configure personalities.*
List of configured personalities. Each personality contains `id`, `name`, `description`, and `system_prompt` fields.
### `timezone`
Timezone setting. Please fill in an IANA timezone name, such as Asia/Shanghai. If empty, the system default timezone is used. See all timezones at: [IANA Time Zone Database](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab).
### `callback_api_base`
Base address for the AstrBot API. Used for file services, plugin callbacks, etc. E.g., `http://example.com:6185`. Default is empty, meaning file services and plugin callbacks are disabled.
### `default_kb_collection`
Default knowledge base name. Used for RAG. If empty, no knowledge base is used.
### `plugin_set`
List of enabled plugins. `*` means all available plugins are enabled. Default is `["*"]`.
+51
View File
@@ -0,0 +1,51 @@
---
outline: deep
---
# AstrBot HTTP API
Starting from v4.18.0, AstrBot provides API Key based HTTP APIs for programmatic access.
## Quick Start
1. Create an API key in WebUI - Settings.
2. Include the API key in request headers:
```http
Authorization: Bearer abk_xxx
```
Also supported:
```http
X-API-Key: abk_xxx
```
3. For chat endpoints, `username` is required:
- `POST /api/v1/chat`: request body must include `username`
- `GET /api/v1/chat/sessions`: query params must include `username`
## Common Endpoints
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
- `GET /api/v1/configs`: list available config files
- `POST /api/v1/file`: upload attachment
- `POST /api/v1/im/message`: proactive message via UMO
- `GET /api/v1/im/bots`: list bot/platform IDs
## Example
```bash
curl -N 'http://localhost:6185/api/v1/chat' \
-H 'Authorization: Bearer abk_xxx' \
-H 'Content-Type: application/json' \
-d '{"message":"Hello","username":"alice"}'
```
## Full API Reference
Use the interactive docs:
- https://docs.astrbot.app/scalar.html
+185
View File
@@ -0,0 +1,185 @@
---
outline: deep
---
# 开发一个平台适配器
AstrBot 支持以插件的形式接入平台适配器,你可以自行接入 AstrBot 没有的平台。如飞书、钉钉甚至是哔哩哔哩私信、Minecraft。
我们以一个平台 `FakePlatform` 为例展开讲解。
首先,在插件目录下新增 `fake_platform_adapter.py``fake_platform_event.py` 文件。前者主要是平台适配器的实现,后者是平台事件的定义。
## 平台适配器
假设 FakePlatform 的客户端 SDK 是这样:
```py
import asyncio
class FakeClient():
'''模拟一个消息平台,这里 5 秒钟下发一个消息'''
def __init__(self, token: str, username: str):
self.token = token
self.username = username
# ...
async def start_polling(self):
while True:
await asyncio.sleep(5)
await getattr(self, 'on_message_received')({
'bot_id': '123',
'content': '新消息',
'username': 'zhangsan',
'userid': '123',
'message_id': 'asdhoashd',
'group_id': 'group123',
})
async def send_text(self, to: str, message: str):
print('发了消息:', to, message)
async def send_image(self, to: str, image_path: str):
print('发了消息:', to, image_path)
```
我们创建 `fake_platform_adapter.py`
```py
import asyncio
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Plain, Image, Record # 消息链中的组件,可以根据需要导入
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import FakeClient
from .fake_platform_event import FakePlatformEvent
# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。
@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={
"token": "your_token",
"username": "bot_username"
})
class FakePlatformAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
self.config = platform_config # 上面的默认配置,用户填写后会传到这里
self.settings = platform_settings # platform_settings 平台设置。
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
# 必须实现
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
# 必须实现,直接像下面一样返回即可。
return PlatformMetadata(
"fake",
"fake 适配器",
)
async def run(self):
# 必须实现,这里是主要逻辑。
# FakeClient 是我们自己定义的,这里只是示例。这个是其回调函数
async def on_received(data):
logger.info(data)
abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
await self.handle_msg(abm)
# 初始化 FakeClient
self.client = FakeClient(self.config['token'], self.config['username'])
self.client.on_message_received = on_received
await self.client.start_polling() # 持续监听消息,这是个堵塞方法。
async def convert_message(self, data: dict) -> AstrBotMessage:
# 将平台消息转换成 AstrBotMessage
# 这里就体现了适配程度,不同平台的消息结构不一样,这里需要根据实际情况进行转换。
abm = AstrBotMessage()
abm.type = MessageType.GROUP_MESSAGE # 还有 friend_message,对应私聊。具体平台具体分析。重要!
abm.group_id = data['group_id'] # 如果是私聊,这里可以不填
abm.message_str = data['content'] # 纯文本消息。重要!
abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # 发送者。重要!
abm.message = [Plain(text=data['content'])] # 消息链。如果有其他类型的消息,直接 append 即可。重要!
abm.raw_message = data # 原始消息。
abm.self_id = data['bot_id']
abm.session_id = data['userid'] # 会话 ID。重要!
abm.message_id = data['message_id'] # 消息 ID。
return abm
async def handle_msg(self, message: AstrBotMessage):
# 处理消息
message_event = FakePlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client
)
self.commit_event(message_event) # 提交事件到事件队列。不要忘记!
```
`fake_platform_event.py`
```py
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from .client import FakeClient
from astrbot.core.utils.io import download_image_by_url
class FakePlatformEvent(AstrMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send(self, message: MessageChain):
for i in message.chain: # 遍历消息链
if isinstance(i, Plain): # 如果是文字类型的
await self.client.send_text(to=self.get_sender_id(), message=i.text)
elif isinstance(i, Image): # 如果是图片类型的
img_url = i.file
img_path = ""
# 下面的三个条件可以直接参考一下。
if img_url.startswith("file:///"):
img_path = img_url[8:]
elif i.file and i.file.startswith("http"):
img_path = await download_image_by_url(i.file)
else:
img_path = img_url
# 请善于 Debug
await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
```
最后,main.py 只需这样,在初始化的时候导入 fake_platform_adapter 模块。装饰器会自动注册。
```py
from astrbot.api.star import Context, Star
class MyPlugin(Star):
def __init__(self, context: Context):
from .fake_platform_adapter import FakePlatformAdapter # noqa
```
搞好后,运行 AstrBot
![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738155926221.png)
这里出现了我们创建的 fake。
![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738155982211.png)
启动后,可以看到正常工作:
![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738156166893.png)
有任何疑问欢迎加群询问~
+1
View File
@@ -0,0 +1 @@
This page has moved to [AstrBot Plugin Development Guide](/en/dev/star/plugin-new).
+489
View File
@@ -0,0 +1,489 @@
# AI
AstrBot provides built-in support for multiple Large Language Model (LLM) providers and offers a unified interface, making it convenient for plugin developers to access various LLM services.
You can use the LLM / Agent interfaces provided by AstrBot to implement your own intelligent agents.
Starting from version `v4.5.7`, we've made significant improvements to the way LLM providers are invoked. We recommend using the new approach, which is more concise and supports additional features. The legacy invocation method remains documented in the previous Chinese-only guide.
## Getting the Chat Model ID for the Current Session
> [!TIP]
> Added in v4.5.7
```py
umo = event.unified_msg_origin
provider_id = await self.context.get_current_chat_provider_id(umo=umo)
```
## Invoking Large Language Models
> [!TIP]
> Added in v4.5.7
```py
llm_resp = await self.context.llm_generate(
chat_provider_id=provider_id, # Chat model ID
prompt="Hello, world!",
)
# print(llm_resp.completion_text) # Get the returned text
```
## Defining Tools
Tools enable large language models to invoke external capabilities.
```py
from pydantic import Field
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
@dataclass
class BilibiliTool(FunctionTool[AstrAgentContext]):
name: str = "bilibili_videos" # Tool name
description: str = "A tool to fetch Bilibili videos." # Tool description
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"keywords": {
"type": "string",
"description": "Keywords to search for Bilibili videos.",
},
},
"required": ["keywords"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
return "1. Video Title: How to Use AstrBot\nVideo Link: xxxxxx"
```
## Invoking Agents
> [!TIP]
> Added in v4.5.7
An Agent can be defined as a combination of system_prompt + tools + llm, enabling more sophisticated intelligent behavior.
After defining the Tool above, you can invoke an Agent as follows:
```py
llm_resp = await self.context.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
prompt="Search for videos related to AstrBot on Bilibili.",
tools=ToolSet([BilibiliTool()]),
max_steps=30, # Maximum agent execution steps
tool_call_timeout=60, # Tool invocation timeout
)
# print(llm_resp.completion_text) # Get the returned text
```
`tool_loop_agent()` method automatically handles the loop of tool invocations and LLM requests until the model stops calling tools or the maximum number of steps is reached.
## Multi-Agent
> [!TIP]
> Added in v4.5.7
Multi-Agent systems decompose complex applications into multiple specialized agents that collaborate to solve problems. Unlike relying on a single agent to handle every step, multi-agent architectures allow smaller, more focused agents to be composed into coordinated workflows. We implement multi-agent systems using the `agent-as-tool` pattern.
In the example below, we define a Main Agent responsible for delegating tasks to different Sub-Agents based on user queries. Each Sub-Agent focuses on specific tasks, such as retrieving weather information.
![multi-agent-example-1](https://files.astrbot.app/docs/en/dev/star/guides/multi-agent-example-1.svg)
Define Tools:
```py
@dataclass
class AssignAgentTool(FunctionTool[AstrAgentContext]):
"""Main agent uses this tool to decide which sub-agent to delegate a task to."""
name: str = "assign_agent"
description: str = "Assign an agent to a task based on the given query"
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The query to call the sub-agent with.",
},
},
"required": ["query"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> str | CallToolResult:
# Here you would implement the actual agent assignment logic.
# For demonstration purposes, we'll return a dummy response.
return "Based on the query, you should assign agent 1."
@dataclass
class WeatherTool(FunctionTool[AstrAgentContext]):
"""In this example, sub agent 1 uses this tool to get weather information."""
name: str = "weather"
description: str = "Get weather information for a location"
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city to get weather information for.",
},
},
"required": ["city"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> str | CallToolResult:
city = kwargs["city"]
# Here you would implement the actual weather fetching logic.
# For demonstration purposes, we'll return a dummy response.
return f"The current weather in {city} is sunny with a temperature of 25°C."
@dataclass
class SubAgent1(FunctionTool[AstrAgentContext]):
"""Define a sub-agent as a function tool."""
name: str = "subagent1_name"
description: str = "subagent1_description"
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The query to call the sub-agent with.",
},
},
"required": ["query"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> str | CallToolResult:
ctx = context.context.context
event = context.context.event
logger.info(f"the llm context messages: {context.messages}")
llm_resp = await ctx.tool_loop_agent(
event=event,
chat_provider_id=await ctx.get_current_chat_provider_id(
event.unified_msg_origin
),
prompt=kwargs["query"],
tools=ToolSet([WeatherTool()]),
max_steps=30,
)
return llm_resp.completion_text
@dataclass
class SubAgent2(FunctionTool[AstrAgentContext]):
"""Define a sub-agent as a function tool."""
name: str = "subagent2_name"
description: str = "subagent2_description"
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The query to call the sub-agent with.",
},
},
"required": ["query"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> str | CallToolResult:
return "I am useless :(, you shouldn't call me :("
```
Then, similarly, invoke the Agent using the `tool_loop_agent()` method:
```py
@filter.command("test")
async def test(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
prov_id = await self.context.get_current_chat_provider_id(umo)
llm_resp = await self.context.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
prompt="Test calling sub-agent for Beijing's weather information.",
system_prompt=(
"You are the main agent. Your task is to delegate tasks to sub-agents based on user queries."
"Before delegating, use the 'assign_agent' tool to determine which sub-agent is best suited for the task."
),
tools=ToolSet([SubAgent1(), SubAgent2(), AssignAgentTool()]),
max_steps=30,
)
yield event.plain_result(llm_resp.completion_text)
```
## Conversation Manager
### Getting the Current LLM Conversation History for a Session
```py
from astrbot.core.conversation_mgr import Conversation
uid = event.unified_msg_origin
conv_mgr = self.context.conversation_manager
curr_cid = await conv_mgr.get_curr_conversation_id(uid)
conversation = await conv_mgr.get_conversation(uid, curr_cid) # Conversation
```
::: details Conversation 类型定义
```py
@dataclass
class Conversation:
"""The conversation entity representing a chat session."""
platform_id: str
"""The platform ID in AstrBot"""
user_id: str
"""The user ID associated with the conversation."""
cid: str
"""The conversation ID, in UUID format."""
history: str = ""
"""The conversation history as a string."""
title: str | None = ""
"""The title of the conversation. For now, it's only used in WebChat."""
persona_id: str | None = ""
"""The persona ID associated with the conversation."""
created_at: int = 0
"""The timestamp when the conversation was created."""
updated_at: int = 0
"""The timestamp when the conversation was last updated."""
```
:::
### Main Methods
#### `new_conversation`
- **Usage**
Create a new conversation in the current session and automatically switch to it.
- **Arguments**
- `unified_msg_origin: str` In the format `platform_name:message_type:session_id`
- `platform_id: str | None` Platform identifier, defaults to parsing from `unified_msg_origin`
- `content: list[dict] | None` Initial message history
- `title: str | None` Conversation title
- `persona_id: str | None` Associated persona ID
- **Returns**
`str` Newly generated UUID conversation ID
#### `switch_conversation`
- **Usage**
Switch the session to a specified conversation.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- **Returns**
`None`
#### `delete_conversation`
- **Usage**
Delete a conversation from the session; if `conversation_id` is `None`, deletes the current conversation.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None`
- **Returns**
`None`
#### `get_curr_conversation_id`
- **Usage**
Get the conversation ID currently in use by the session.
- **Arguments**
- `unified_msg_origin: str`
- **Returns**
`str | None` Current conversation ID, returns `None` if it doesn't exist
#### `get_conversation`
- **Usage**
Get the complete object for a specified conversation; automatically creates it if it doesn't exist and `create_if_not_exists=True`.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- `create_if_not_exists: bool = False`
- **Returns**
`Conversation | None`
#### `get_conversations`
- **Usage**
Retrieve the complete list of conversations for a user or platform.
- **Arguments**
- `unified_msg_origin: str | None` When `None`, does not filter by user
- `platform_id: str | None`
- **Returns**
`List[Conversation]`
#### `update_conversation`
- **Usage**
Update the title, history, or persona_id of a conversation.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None` Uses the current conversation when `None`
- `history: list[dict] | None`
- `title: str | None`
- `persona_id: str | None`
- **Returns**
`None`
## Persona Manager
`PersonaManager` is responsible for unified loading, caching, and providing CRUD interfaces for all Personas, while maintaining compatibility with the legacy persona format (v3) from before AstrBot 4.x.
During initialization, it automatically reads all personas from the database and generates v3-compatible data for seamless use with legacy code.
```py
persona_mgr = self.context.persona_manager
```
### Main Methods
#### `get_persona`
- **Usage**
Get persona data by persona ID.
- **Arguments**
- `persona_id: str` Persona ID
- **Returns**
`Persona` Persona data, returns None if it doesn't exist
- **Raises**
`ValueError` Raised when it doesn't exist
#### `get_all_personas`
- **Usage**
Retrieve all personas from the database at once.
- **Returns**
`list[Persona]` Persona list, may be empty
#### `create_persona`
- **Usage**
Create a new persona and immediately write it to the database; automatically refreshes the local cache upon success.
- **Arguments**
- `persona_id: str` New persona ID (unique)
- `system_prompt: str` System prompt
- `begin_dialogs: list[str]` Optional, opening dialogs (even number of entries, alternating user/assistant)
- `tools: list[str]` Optional, list of allowed tools; `None`=all tools, `[]`=disable all
- **Returns**
`Persona` Newly created persona object
- **Raises**
`ValueError` If `persona_id` already exists
#### `update_persona`
- **Usage**
Update any fields of an existing persona and synchronize to database and cache.
- **Arguments**
- `persona_id: str` Persona ID to update
- `system_prompt: str` Optional, new system prompt
- `begin_dialogs: list[str]` Optional, new opening dialogs
- `tools: list[str]` Optional, new tool list; semantics same as `create_persona`
- **Returns**
`Persona` Updated persona object
- **Raises**
`ValueError` If `persona_id` doesn't exist
#### `delete_persona`
- **Usage**
Delete the specified persona and clean up both database and cache.
- **Arguments**
- `persona_id: str` Persona ID to delete
- **Raises**
`ValueError` If `persona_id` doesn't exist
#### `get_default_persona_v3`
- **Usage**
Get the default persona (v3 format) to use based on the current session configuration.
Falls back to `DEFAULT_PERSONALITY` if configuration doesn't specify one or the specified persona doesn't exist.
- **Arguments**
- `umo: str | MessageSession | None` Session identifier, used to read user-level configuration
- **Returns**
`Personality` Default persona object in v3 format
::: details Persona / Personality 类型定义
```py
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
It can be used to customize the behavior of LLMs.
"""
__tablename__ = "personas"
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
persona_id: str = Field(max_length=255, nullable=False)
system_prompt: str = Field(sa_type=Text, nullable=False)
begin_dialogs: Optional[list] = Field(default=None, sa_type=JSON)
"""a list of strings, each representing a dialog to start with"""
tools: Optional[list] = Field(default=None, sa_type=JSON)
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"persona_id",
name="uix_persona_id",
),
)
class Personality(TypedDict):
"""LLM Persona class.
Starting from v4.0.0 and later, it's recommended to use the Persona class above. Additionally, the mood_imitation_dialogs field has been deprecated.
"""
prompt: str
name: str
begin_dialogs: list[str]
mood_imitation_dialogs: list[str]
"""Mood imitation dialog preset. Deprecated since v4.0.0 and later."""
tools: list[str] | None
"""Tool list. None means use all tools, empty list means don't use any tools"""
```
:::
+48
View File
@@ -0,0 +1,48 @@
# 开发环境准备
## 获取插件模板
1. 打开 AstrBot 插件模板: [helloworld](https://github.com/Soulter/helloworld)
2. 点击右上角的 `Use this template`
3. 然后点击 `Create new repository`
4. 在 `Repository name` 处填写您的插件名。插件名格式:
- 推荐以 `astrbot_plugin_` 开头;
- 不能包含空格;
- 保持全部字母小写;
- 尽量简短。
5. 点击右下角的 `Create repository`
![New repo](https://files.astrbot.app/docs/source/images/plugin/image.png)
## Clone 插件和 AstrBot 项目
Clone AstrBot 项目本体和刚刚创建的插件仓库到本地。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
mkdir -p AstrBot/data/plugins
cd AstrBot/data/plugins
git clone 插件仓库地址
```
然后,使用 `VSCode` 打开 `AstrBot` 项目。找到 `data/plugins/<你的插件名字>` 目录。
更新 `metadata.yaml` 文件,填写插件的元数据信息。
> [!NOTE]
> AstrBot 插件市场的信息展示依赖于 `metadata.yaml` 文件。
## 调试插件
AstrBot 采用在运行时注入插件的机制。因此,在调试插件时,需要启动 AstrBot 本体。
您可以使用 AstrBot 的热重载功能简化开发流程。
插件的代码修改后,可以在 AstrBot WebUI 的插件管理处找到自己的插件,点击右上角 `...` 按钮,选择 `重载插件`
## 插件依赖管理
目前 AstrBot 对插件的依赖管理使用 `pip` 自带的 `requirements.txt` 文件。如果你的插件需要依赖第三方库,请务必在插件目录下创建 `requirements.txt` 文件并写入所使用的依赖库,以防止用户在安装你的插件时出现依赖未找到(Module Not Found)的问题。
> `requirements.txt` 的完整格式可以参考 [pip 官方文档](https://pip.pypa.io/en/stable/reference/requirements-file-format/)。
+66
View File
@@ -0,0 +1,66 @@
# Text to Image
> [!TIP]
> For easier development, you can use the [AstrBot Text2Image Playground](https://t2i-playground.astrbot.app/) for online visual editing and testing of HTML templates.
## Basic Usage
AstrBot supports rendering text into images.
```python
@filter.command("image") # Register an /image command that accepts a text parameter.
async def on_aiocqhttp(self, event: AstrMessageEvent, text: str):
url = await self.text_to_image(text) # text_to_image() is a method of the Star class.
# path = await self.text_to_image(text, return_url = False) # If you want to save the image locally
yield event.image_result(url)
```
![image](https://files.astrbot.app/docs/source/images/plugin/image-3.png)
## Customization (HTML-Based)
If you find the default rendered images insufficiently aesthetic, you can use custom HTML templates to render images.
AstrBot supports rendering text-to-image templates using `HTML + Jinja2`.
```py{7}
# Custom Jinja2 template with CSS support
TMPL = '''
<div style="font-size: 32px;">
<h1 style="color: black">Todo List</h1>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</div>
'''
@filter.command("todo")
async def custom_t2i_tmpl(self, event: AstrMessageEvent):
options = {} # Optionally pass rendering options.
url = await self.html_render(TMPL, {"items": ["Eat", "Sleep", "Play Genshin"]}, options=options) # The second parameter is the data for Jinja2 rendering
yield event.image_result(url)
```
The result:
![image](https://files.astrbot.app/docs/source/images/plugin/fcc2dcb472a91b12899f617477adc5c7.png)
This is just a simple example. Thanks to the powerful capabilities of HTML and DOM renderers, you can create more complex and visually appealing designs. Additionally, Jinja2 supports syntax for loops, conditionals, and more to accommodate data structures like lists and dictionaries. You can learn more about Jinja2 online.
**Image Rendering Options (options)**:
Please refer to Playwright's [screenshot](https://playwright.dev/python/docs/api/class-page#page-screenshot) API.
- `timeout` (float, optional): Screenshot timeout duration.
- `type` (Literal["jpeg", "png"], optional): Screenshot image type.
- `quality` (int, optional): Screenshot quality, only applicable to JPEG format images.
- `omit_background` (bool, optional): Whether to hide the default white background, allowing transparent screenshots. Only applicable to PNG format.
- `full_page` (bool, optional): Whether to capture the entire page rather than just the viewport size. Defaults to True.
- `clip` (dict, optional): The region to crop after taking the screenshot. Refer to Playwright's screenshot API.
- `animations`: (Literal["allow", "disabled"], optional): Whether to allow CSS animations to play.
- `caret`: (Literal["hide", "initial"], optional): When set to hide, the text cursor will be hidden during the screenshot. Defaults to hide.
- `scale`: (Literal["css", "device"], optional): Page scaling setting. When set to css, device resolution maps one-to-one with CSS pixels, which may result in smaller screenshots on high-DPI screens. When set to device, scaling is based on the device's screen scaling settings or the device_scale_factor parameter in the current Playwright Page/Context.
@@ -0,0 +1,348 @@
# Handling Message Events
Event listeners can receive message content delivered by the platform and implement features such as commands, command groups, and event listening.
Event listener decorators are located in `astrbot.api.event.filter` and must be imported first. Please make sure to import it, otherwise it will conflict with Python's built-in `filter` higher-order function.
```py
from astrbot.api.event import filter, AstrMessageEvent
```
## Messages and Events
AstrBot receives messages delivered by messaging platforms and encapsulates them as `AstrMessageEvent` objects, which are then passed to plugins for processing.
![message-event](https://files.astrbot.app/docs/en/dev/star/guides/message-event.svg)
### Message Events
`AstrMessageEvent` is AstrBot's message event object, which stores information about the message sender, message content, etc.
### Message Object
`AstrBotMessage` is AstrBot's message object, which stores the specific content of messages delivered by the messaging platform. The `AstrMessageEvent` object contains a `message_obj` attribute to retrieve this message object.
```py{11}
class AstrBotMessage:
'''AstrBot's message object'''
type: MessageType # Message type
self_id: str # Bot's identification ID
session_id: str # Session ID. Depends on the unique_session setting.
message_id: str # Message ID
group_id: str = "" # Group ID, empty if it's a private chat
sender: MessageMember # Sender
message: List[BaseMessageComponent] # Message chain. For example: [Plain("Hello"), At(qq=123456)]
message_str: str # The most straightforward plain text message string, concatenating Plain messages (text messages) from the message chain
raw_message: object
timestamp: int # Message timestamp
```
Here, `raw_message` is the **raw message object** from the messaging platform adapter.
### Message Chain
![message-chain](https://files.astrbot.app/docs/en/dev/star/guides/message-chain.svg)
A `message chain` describes the structure of a message. It's an ordered list where each element is called a `message segment`.
Common message segment types include:
- `Plain`: Text message segment
- `At`: Mention message segment
- `Image`: Image message segment
- `Record`: Audio message segment
- `Video`: Video message segment
- `File`: File message segment
Most messaging platforms support the above message segment types.
Additionally, the OneBot v11 platform (QQ personal accounts, etc.) also supports the following common message segment types:
- `Face`: Emoji message segment
- `Node`: A node in a forward message
- `Nodes`: Multiple nodes in a forward message
- `Poke`: Poke message segment
In AstrBot, message chains are represented as lists of type `List[BaseMessageComponent]`.
## Commands
![message-event-simple-command](https://files.astrbot.app/docs/en/dev/star/guides/message-event-simple-command.svg)
```python
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
@filter.command("helloworld") # from astrbot.api.event.filter import command
async def helloworld(self, event: AstrMessageEvent):
'''This is a hello world command'''
user_name = event.get_sender_name()
message_str = event.message_str # Get the plain text content of the message
yield event.plain_result(f"Hello, {user_name}!")
```
> [!TIP]
> Commands cannot contain spaces, otherwise AstrBot will parse them as a second parameter. You can use the command group feature below, or use a listener to parse the message content yourself.
## Commands with Parameters
![command-with-param](https://files.astrbot.app/docs/en/dev/star/guides/command-with-param.svg)
AstrBot will automatically parse command parameters for you.
```python
@filter.command("add")
def add(self, event: AstrMessageEvent, a: int, b: int):
# /add 1 2 -> Result is: 3
yield event.plain_result(f"Wow! The answer is {a + b}!")
```
## Command Groups
Command groups help you organize commands.
```python
@filter.command_group("math")
def math(self):
pass
@math.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
# /math add 1 2 -> Result is: 3
yield event.plain_result(f"Result is: {a + b}")
@math.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
# /math sub 1 2 -> Result is: -1
yield event.plain_result(f"Result is: {a - b}")
```
The command group function doesn't need to implement any logic; just use `pass` directly or add comments within the function. Subcommands of the command group are registered using `command_group_name.command`.
When a user doesn't input a subcommand, an error will be reported and the tree structure of the command group will be rendered.
![image](https://files.astrbot.app/docs/source/images/plugin/image-1.png)
![image](https://files.astrbot.app/docs/source/images/plugin/898a169ae7ed0478f41c0a7d14cb4d64.png)
![image](https://files.astrbot.app/docs/source/images/plugin/image-2.png)
Theoretically, command groups can be nested infinitely!
```py
'''
math
├── calc
│ ├── add (a(int),b(int),)
│ ├── sub (a(int),b(int),)
│ ├── help (command with no parameters)
'''
@filter.command_group("math")
def math():
pass
@math.group("calc") # Note: this is group, not command_group
def calc():
pass
@calc.command("add")
async def add(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"Result is: {a + b}")
@calc.command("sub")
async def sub(self, event: AstrMessageEvent, a: int, b: int):
yield event.plain_result(f"Result is: {a - b}")
@calc.command("help")
def calc_help(self, event: AstrMessageEvent):
# /math calc help
yield event.plain_result("This is a calculator plugin with add and sub commands.")
```
## Command Aliases
> Available after v3.4.28
You can add different aliases for commands or command groups:
```python
@filter.command("help", alias={'帮助', 'helpme'})
def help(self, event: AstrMessageEvent):
yield event.plain_result("This is a calculator plugin with add and sub commands.")
```
### Event Type Filtering
#### Receive All
This will receive all events.
```python
@filter.event_message_type(filter.EventMessageType.ALL)
async def on_all_message(self, event: AstrMessageEvent):
yield event.plain_result("Received a message.")
```
#### Group Chat and Private Chat
```python
@filter.event_message_type(filter.EventMessageType.PRIVATE_MESSAGE)
async def on_private_message(self, event: AstrMessageEvent):
message_str = event.message_str # Get the plain text content of the message
yield event.plain_result("Received a private message.")
```
`EventMessageType` is an `Enum` type that contains all event types. Current event types are `PRIVATE_MESSAGE` and `GROUP_MESSAGE`.
#### Messaging Platform
```python
@filter.platform_adapter_type(filter.PlatformAdapterType.AIOCQHTTP | filter.PlatformAdapterType.QQOFFICIAL)
async def on_aiocqhttp(self, event: AstrMessageEvent):
'''Only receive messages from AIOCQHTTP and QQOFFICIAL'''
yield event.plain_result("Received a message")
```
In the current version, `PlatformAdapterType` includes `AIOCQHTTP`, `QQOFFICIAL`, `GEWECHAT`, and `ALL`.
#### Admin Commands
```python
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("test")
async def test(self, event: AstrMessageEvent):
pass
```
Only admins can use the `test` command.
### Multiple Filters
Multiple filters can be used simultaneously by adding multiple decorators to a function. Filters use `AND` logic, meaning the function will only execute if all filters pass.
```python
@filter.command("helloworld")
@filter.event_message_type(filter.EventMessageType.PRIVATE_MESSAGE)
async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
```
### Event Hooks
> [!TIP]
> Event hooks do not support being used together with @filter.command, @filter.command_group, @filter.event_message_type, @filter.platform_adapter_type, or @filter.permission_type.
#### On Bot Initialization Complete
> Available after v3.4.34
```python
from astrbot.api.event import filter, AstrMessageEvent
@filter.on_astrbot_loaded()
async def on_astrbot_loaded(self):
print("AstrBot initialization complete")
```
#### On LLM Request
In AstrBot's default execution flow, the `on_llm_request` hook is triggered before calling the LLM.
You can obtain the `ProviderRequest` object and modify it.
The ProviderRequest object contains all information about the LLM request, including the request text, system prompt, etc.
```python
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import ProviderRequest
@filter.on_llm_request()
async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest): # Note there are three parameters
print(req) # Print the request text
req.system_prompt += "Custom system_prompt"
```
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
#### On LLM Response Complete
After the LLM request completes, the `on_llm_response` hook is triggered.
You can obtain the `ProviderResponse` object and modify it.
```python
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.provider import LLMResponse
@filter.on_llm_response()
async def on_llm_resp(self, event: AstrMessageEvent, resp: LLMResponse): # Note there are three 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.
You can implement some message decoration here, such as converting to voice, converting to image, adding prefixes, etc.
```python
from astrbot.api.event import filter, AstrMessageEvent
@filter.on_decorating_result()
async def on_decorating_result(self, event: AstrMessageEvent):
result = event.get_result()
chain = result.chain
print(chain) # Print the message chain
chain.append(Plain("!")) # Add an exclamation mark at the end of the message chain
```
> You cannot use yield to send messages here. This hook is only for decorating event.get_result().chain. If you need to send, please use the `event.send()` method directly.
#### After Message Sent
After a message is sent to the messaging platform, the `after_message_sent` hook is triggered.
```python
from astrbot.api.event import filter, AstrMessageEvent
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent):
pass
```
> You cannot use yield to send messages here. If you need to send, please use the `event.send()` method directly.
### Priority
Commands, event listeners, and event hooks can have priority set to execute before other commands, listeners, or hooks. The default priority is `0`.
```python
@filter.command("helloworld", priority=1)
async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
```
## Controlling Event Propagation
```python{6}
@filter.command("check_ok")
async def check_ok(self, event: AstrMessageEvent):
ok = self.check() # Your own logic
if not ok:
yield event.plain_result("Check failed")
event.stop_event() # Stop event propagation
```
When event propagation is stopped, all subsequent steps will not be executed.
Assuming there's a plugin A, after A terminates event propagation, all subsequent operations will not be executed, such as executing other plugins' handlers or requesting the LLM.
+211
View File
@@ -0,0 +1,211 @@
# Plugin Configuration
As plugin functionality grows, you may need to define configurations to allow users to customize plugin behavior.
AstrBot provides "powerful" configuration parsing and visualization features. Users can configure plugins directly in the management panel without modifying code.
## Configuration Definition
To register configurations, first add a `_conf_schema.json` JSON file in your plugin directory.
The file content is a `Schema` that represents the configuration. The Schema is in JSON format, for example:
```json
{
"token": {
"description": "Bot Token",
"type": "string",
},
"sub_config": {
"description": "Test nested configuration",
"type": "object",
"hint": "xxxx",
"items": {
"name": {
"description": "testsub",
"type": "string",
"hint": "xxxx"
},
"id": {
"description": "testsub",
"type": "int",
"hint": "xxxx"
},
"time": {
"description": "testsub",
"type": "int",
"hint": "xxxx",
"default": 123
}
}
}
}
```
- `type`: **Required**. The type of the configuration. Supports `string`, `text`, `int`, `float`, `bool`, `object`, `list`, `dict`, `template_list`, `file`. When the type is `text`, it will be visualized as a larger resizable textarea component to accommodate large text.
- `description`: Optional. Description of the configuration. A one-sentence description of the configuration's behavior is recommended.
- `hint`: Optional. Hint information for the configuration, displayed in the question mark button on the right in the image above, shown when hovering over it.
- `obvious_hint`: Optional. Whether the configuration hint should be prominently displayed, like `token` in the image above.
- `default`: Optional. The default value of the configuration. If the user hasn't configured it, the default value will be used. Default values: int is 0, float is 0.0, bool is False, string is "", object is {}, list is [].
- `items`: Optional. If the configuration type is `object`, the `items` field needs to be added. The content of `items` is the sub-Schema of this configuration item. Theoretically, it can be nested infinitely, but excessive nesting is not recommended.
- `invisible`: Optional. Whether the configuration is hidden. Default is `false`. If set to `true`, it will not be displayed in the management panel.
- `options`: Optional. A list, such as `"options": ["chat", "agent", "workflow"]`. Provides dropdown list options.
- `editor_mode`: Optional. Whether to enable code editor mode. Requires AstrBot >= `v3.5.10`. Versions below this won't report errors but won't take effect. Default is false.
- `editor_language`: Optional. The code language for the code editor, defaults to `json`.
- `editor_theme`: Optional. The theme for the code editor. Options are `vs-light` (default) and `vs-dark`.
- `_special`: Optional. Used to call AstrBot's visualization features for provider selection, persona selection, knowledge base selection, etc. See details below.
When the code editor is enabled, it looks like this:
![editor_mode](https://files.astrbot.app/docs/source/images/plugin/image-6.png)
![editor_mode_fullscreen](https://files.astrbot.app/docs/source/images/plugin/image-7.png)
The **_special** field is only available after v4.0.0. Currently supports `select_provider`, `select_provider_tts`, `select_provider_stt`, `select_persona`, allowing users to quickly select model providers, personas, and other data already configured in the WebUI. Results are all strings. Using select_provider as an example, it will present the following effect:
![image](https://files.astrbot.app/docs/source/images/plugin/image-select-provider.png)
### `file` type schema
Introduced in v4.13.0, this allows plugins to define file-upload configuration items to guide users to upload files required by the plugin.
```json
{
"demo_files": {
"type": "file",
"description": "Uploaded files for demo",
"default": [],
"file_types": ["pdf", "docx"]
}
}
```
### `dict` type schema
Used to visualize editing a Python `dict` type configuration. For example, AstrBot Core's custom extra body parameter configuration:
```py
"custom_extra_body": {
"description": "Custom request body parameters",
"type": "dict",
"items": {},
"hint": "Used to add extra parameters to requests, such as temperature, top_p, max_tokens, etc.",
"template_schema": {
"temperature": {
"name": "Temperature",
"description": "Temperature parameter",
"hint": "Controls randomness of output, typically 0-2. Higher is more random.",
"type": "float",
"default": 0.6,
"slider": {"min": 0, "max": 2, "step": 0.1},
},
"top_p": {
"name": "Top-p",
"description": "Top-p sampling",
"hint": "Nucleus sampling parameter, typically 0-1. Controls probability mass considered.",
"type": "float",
"default": 1.0,
"slider": {"min": 0, "max": 1, "step": 0.01},
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum tokens",
"hint": "Maximum number of tokens to generate.",
"type": "int",
"default": 8192,
},
},
}
```
### `template_list` type schema
> [!NOTE]
> Introduced in v4.10.4. For more details see: [#4208](https://github.com/AstrBotDevs/AstrBot/pull/4208)
Plugin developers can add a template-style configuration to `_conf_schema` in the following format (somewhat similar to nested configs):
```json
"field_id": {
"type": "template_list",
"description": "Template List Field",
"templates": {
"template_1": {
"name": "Template One",
"hint":"hint",
"items": {
"attr_a": {
"description": "Attribute A",
"type": "int",
"default": 10
},
"attr_b": {
"description": "Attribute B",
"hint": "This is a boolean attribute",
"type": "bool",
"default": true
}
}
},
"template_2": {
"name": "Template Two",
"hint":"hint",
"items": {
"attr_c": {
"description": "Attribute A",
"type": "int",
"default": 10
},
"attr_d": {
"description": "Attribute B",
"hint": "This is a boolean attribute",
"type": "bool",
"default": true
}
}
}
}
}
```
Saved config example:
```json
"field_id": [
{
"__template_key": "template_1",
"attr_a": 10,
"attr_b": true
},
{
"__template_key": "template_2",
"attr_c": 10,
"attr_d": true
}
]
```
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
## Using Configuration in Plugins
When loading plugins, AstrBot will check if there's a `_conf_schema.json` file in the plugin directory. If it exists, it will automatically parse the configuration and save it under `data/config/<plugin_name>_config.json` (a configuration file entity created according to the Schema), and pass it to `__init__()` when instantiating the plugin class.
```py
from astrbot.api import AstrBotConfig
class ConfigPlugin(Star):
def __init__(self, context: Context, config: AstrBotConfig): # AstrBotConfig inherits from Dict and has all dictionary methods
super().__init__(context)
self.config = config
print(self.config)
# Supports direct configuration saving
# self.config.save_config() # Save configuration
```
## Configuration Updates
When you update the Schema across different versions, AstrBot will recursively inspect the configuration items in the Schema, automatically adding default values for missing items and removing those that no longer exist.
+131
View File
@@ -0,0 +1,131 @@
# Sending Messages
## Passive Messages
Passive messages refer to the bot responding to messages reactively.
```python
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
yield event.plain_result("Hello!")
yield event.plain_result("你好!")
yield event.image_result("path/to/image.jpg") # Send an image
yield event.image_result("https://example.com/image.jpg") # Send an image from URL, must start with http or https
```
## Active Messages
Active messages refer to the bot proactively pushing messages. Some platforms may not support active message sending.
For scheduled tasks or when you don't want to send messages immediately, you can use `event.unified_msg_origin` to get a string and store it, then use `self.context.send_message(unified_msg_origin, chains)` to send messages when needed.
```python
from astrbot.api.event import MessageChain
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
umo = event.unified_msg_origin
message_chain = MessageChain().message("Hello!").file_image("path/to/image.jpg")
await self.context.send_message(event.unified_msg_origin, message_chain)
```
With this feature, you can store the `unified_msg_origin` and send messages when needed.
> [!TIP]
> About unified_msg_origin.
> `unified_msg_origin` is a string that records the unique ID of a session. AstrBot uses it to identify which messaging platform and which session it belongs to. This allows messages to be sent to the correct session when using `send_message`. For more about MessageChain, see the next section.
## Rich Media Messages
AstrBot supports sending rich media messages such as images, audio, videos, etc. Use `MessageChain` to construct messages.
```python
import astrbot.api.message_components as Comp
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
chain = [
Comp.At(qq=event.get_sender_id()), # Mention the message sender
Comp.Plain("Check out this image:"),
Comp.Image.fromURL("https://example.com/image.jpg"), # Send image from URL
Comp.Image.fromFileSystem("path/to/image.jpg"), # Send image from local file system
Comp.Plain("This is an image.")
]
yield event.chain_result(chain)
```
The above constructs a `message chain`, which will ultimately send a message containing both images and text while preserving the order.
> [!TIP]
> In the aiocqhttp message adapter, for messages of type `plain`, the `strip()` method is used during sending to remove spaces and line breaks. You can add zero-width spaces `\u200b` before and after the message to resolve this issue.
Similarly,
**File**
```py
Comp.File(file="path/to/file.txt", name="file.txt") # Not supported by some platforms
```
**Audio Record**
```py
path = "path/to/record.wav" # Currently only accepts wav format, please convert other formats yourself
Comp.Record(file=path, url=path)
```
**Video**
```py
path = "path/to/video.mp4"
Comp.Video.fromFileSystem(path=path)
Comp.Video.fromURL(url="https://example.com/video.mp4")
```
## Sending Video Messages
```python
from astrbot.api.event import filter, AstrMessageEvent
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Video
# fromFileSystem requires the user's protocol client and bot to be on the same system.
music = Video.fromFileSystem(
path="test.mp4"
)
# More universal approach
music = Video.fromURL(
url="https://example.com/video.mp4"
)
yield event.chain_result([music])
```
![Sending video messages](https://files.astrbot.app/docs/source/images/plugin/db93a2bb-671c-4332-b8ba-9a91c35623c2.png)
## Sending Group Forward Messages
> Most platforms do not support this message type. Current support: OneBot v11
You can send group forward messages as follows.
```py
from astrbot.api.event import filter, AstrMessageEvent
@filter.command("test")
async def test(self, event: AstrMessageEvent):
from astrbot.api.message_components import Node, Plain, Image
node = Node(
uin=905617992,
name="Soulter",
content=[
Plain("hi"),
Image.fromFileSystem("test.jpg")
]
)
yield event.chain_result([node])
```
![Sending group forward messages](https://files.astrbot.app/docs/source/images/plugin/image-4.png)
+113
View File
@@ -0,0 +1,113 @@
# Session Control
> v3.4.36 and above
Why do we need session control? Consider a Chinese idiom chain game plugin where a user or group needs to have multiple conversations with the bot rather than a one-time command. This is when session control becomes necessary.
```txt
User: /idiom-chain
Bot: Please send an idiom
User: One horse takes the lead (一马当先)
Bot: Foresight (先见之明)
User: Keen observation (明察秋毫)
...
```
AstrBot provides out-of-the-box session control functionality:
Import:
```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
session_waiter,
SessionController,
)
```
Code within the handler can be written as follows:
```python
from astrbot.api.event import filter, AstrMessageEvent
@filter.command("idiom-chain")
async def handle_empty_mention(self, event: AstrMessageEvent):
"""Idiom chain game implementation"""
try:
yield event.plain_result("Please send an idiom~")
# How to use the session controller
@session_waiter(timeout=60, record_history_chains=False) # Register a session controller with a 60-second timeout, without recording message history
async def empty_mention_waiter(controller: SessionController, event: AstrMessageEvent):
idiom = event.message_str # The idiom sent by the user, e.g., "one horse takes the lead"
if idiom == "exit": # If the user wants to exit the idiom chain game by typing "exit"
await event.send(event.plain_result("Exited the idiom chain game~"))
controller.stop() # Stop the session controller, which will end immediately.
return
if len(idiom) != 4: # If the user's input is not a 4-character idiom
await event.send(event.plain_result("The idiom must be four characters~")) # Send a reply, cannot use yield
return
# Exit the current method without executing subsequent logic, but the session is not interrupted; subsequent user input will still enter the current session
# ...
message_result = event.make_result()
message_result.chain = [Comp.Plain("Foresight")] # import astrbot.api.message_components as Comp
await event.send(message_result) # Send a reply, cannot use yield
controller.keep(timeout=60, reset_timeout=True) # Reset timeout to 60s. If not reset, it will continue the previous timeout countdown.
# controller.stop() # Stop the session controller, which will end immediately.
# If history chains are recorded, you can retrieve them via controller.get_history_chains()
try:
await empty_mention_waiter(event)
except TimeoutError as _: # When timeout occurs, the session controller will raise TimeoutError
yield event.plain_result("You timed out!")
except Exception as e:
yield event.plain_result("An error occurred, please contact the administrator: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
```
Once the session controller is activated, messages subsequently sent by that sender will first be processed by the `empty_mention_waiter` function you defined above, until the session controller is stopped or times out.
## SessionController
Used by developers to control whether a session should end, and to retrieve message history chains.
- keep(): Keep this session alive
- timeout (float): Required. Session timeout duration.
- reset_timeout (bool): When set to True, it resets the timeout; timeout must be > 0, if <= 0 the session ends immediately. When set to False, it maintains the original timeout; new timeout = remaining timeout + timeout (can be < 0)
- stop(): End this session
- get_history_chains() -> List[List[Comp.BaseMessageComponent]]: Retrieve message history chains
## Custom Session ID Filter
By default, the AstrBot session controller uses `sender_id` (the sender's ID) as the identifier for distinguishing different sessions. If you want to treat an entire group as one session, you need to customize the session ID filter.
```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
session_waiter,
SessionFilter,
SessionController,
)
# Using the handler from above
# ...
class CustomFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
return event.get_group_id() if event.get_group_id() else event.unified_msg_origin
await empty_mention_waiter(event, session_filter=CustomFilter()) # Pass in session_filter here
# ...
```
After this setup, when a user in a group sends a message, the session controller will treat the entire group as one session, and messages from other users in the group will also be considered part of the same session.
You can even use this feature to enable team-based activities within groups!
+58
View File
@@ -0,0 +1,58 @@
# Minimal Example
The `main.py` file in the plugin template is a minimal plugin instance.
```python
from astrbot.api.event import filter, AstrMessageEvent, MessageEventResult
from astrbot.api.star import Context, Star
from astrbot.api import logger # Use the logger interface provided by AstrBot
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
# Decorator to register a command. The command name is "helloworld". Once registered, sending `/helloworld` will trigger this command and respond with `Hello, {user_name}!`
@filter.command("helloworld")
async def helloworld(self, event: AstrMessageEvent):
'''This is a hello world command''' # This is the handler's description, which will be parsed to help users understand the plugin's functionality. Highly recommended to provide.
user_name = event.get_sender_name()
message_str = event.message_str # Get the plain text content of the message
logger.info("Hello world command triggered!")
yield event.plain_result(f"Hello, {user_name}!") # Send a plain text message
async def terminate(self):
'''Optionally implement the terminate function, which will be called when the plugin is uninstalled/disabled.'''
```
Explanation:
- Plugins must inherit from the `Star` class.
- The `Context` class is used for plugin interaction with AstrBot Core, allowing you to call various APIs provided by AstrBot Core.
- Specific handler functions are defined within the plugin class, such as the `helloworld` function here.
- `AstrMessageEvent` is AstrBot's message event object, which stores information about the message sender, message content, etc.
- `AstrBotMessage` is AstrBot's message object, which stores the specific content of messages delivered by the messaging platform. It can be accessed via `event.message_obj`.
> [!TIP]
>
> Handlers must be registered within the plugin class, with the first two parameters being `self` and `event`. If the file becomes too long, you can write services externally and call them from the handler.
>
> The file containing the plugin class must be named `main.py`.
All handler functions must be written within the plugin class. To keep content concise, in subsequent sections, we may omit the plugin class definition.
```
解释如下:
- 插件需要继承 `Star` 类。
- `Context` 类用于插件与 AstrBot Core 交互,可以由此调用 AstrBot Core 提供的各种 API。
- 具体的处理函数 `Handler` 在插件类中定义,如这里的 `helloworld` 函数。
- `AstrMessageEvent` 是 AstrBot 的消息事件对象,存储了消息发送者、消息内容等信息。
- `AstrBotMessage` 是 AstrBot 的消息对象,存储了消息平台下发的消息的具体内容。可以通过 `event.message_obj` 获取。
> [!TIP]
>
> `Handler` 一定需要在插件类中注册,前两个参数必须为 `self``event`。如果文件行数过长,可以将服务写在外部,然后在 `Handler` 中调用。
>
> 插件类所在的文件名需要命名为 `main.py`
所有的处理函数都需写在插件类中。为了精简内容,在之后的章节中,我们可能会忽略插件类的定义。
+31
View File
@@ -0,0 +1,31 @@
# Plugin Storage
## Simple KV Storage
> [!TIP]
> Requires AstrBot version >= 4.9.2.
Plugins can use AstrBot's simple key-value store to persist configuration or temporary data. The storage is scoped per plugin, so each plugin has its own isolated space.
```py
class Main(star.Star):
@filter.command("hello")
async def hello(self, event: AstrMessageEvent):
"""Aloha!"""
await self.put_kv_data("greeted", True)
greeted = await self.get_kv_data("greeted", False)
await self.delete_kv_data("greeted")
```
## Large File Storage Convention
To keep large file handling consistent, store large files under `data/plugin_data/{plugin_name}/`.
You can fetch the plugin data directory with:
```py
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
plugin_data_path = get_astrbot_data_path() / "plugin_data" / self.name # self.name is the plugin name; available in v4.9.2 and above. For lower versions, specify the plugin name yourself.
```
+128
View File
@@ -0,0 +1,128 @@
---
outline: deep
---
# AstrBot Plugin Development Guide 🌠
Welcome to the AstrBot Plugin Development Guide! This section will guide you through developing AstrBot plugins. Before we begin, we hope you have the following foundational knowledge:
1. Some experience with Python programming.
2. Some experience with Git and GitHub.
## Environment Setup
### Obtain the Plugin Template
1. Open the AstrBot plugin template: [helloworld](https://github.com/Soulter/helloworld)
2. Click `Use this template` in the upper right corner
3. Then click `Create new repository`.
4. Fill in your plugin name in the `Repository name` field. Plugin naming conventions:
- Recommended to start with `astrbot_plugin_`;
- Must not contain spaces;
- Keep all letters lowercase;
- Keep it concise.
5. Click `Create repository` in the lower right corner.
### Clone the Project Locally
Clone both the AstrBot main project and the plugin repository you just created to your local machine.
```bash
git clone https://github.com/AstrBotDevs/AstrBot
mkdir -p AstrBot/data/plugins
cd AstrBot/data/plugins
git clone <your-plugin-repository-url>
```
Then, use `VSCode` to open the `AstrBot` project. Navigate to the `data/plugins/<your-plugin-name>` directory.
Update the `metadata.yaml` file with your plugin's metadata information.
> [!WARNING]
> Please make sure to modify this file, as AstrBot relies on the `metadata.yaml` file to recognize plugin metadata.
### Set Plugin Logo (Optional)
You can add a `logo.png` file in the plugin directory as the plugin's logo. Please maintain an aspect ratio of 1:1, with a recommended size of 256x256.
![Plugin logo example](https://files.astrbot.app/docs/source/images/plugin/plugin_logo.png)
### Plugin Display Name (Optional)
You can modify (or add) the `display_name` field in the `metadata.yaml` file to serve as the plugin's display name in scenarios like the plugin marketplace, making it easier for users to read.
### Declare Supported Platforms (Optional)
You can add a `support_platforms` field (`list[str]`) to `metadata.yaml` to declare which platform adapters your plugin supports. The WebUI plugin page will display this field.
```yaml
support_platforms:
- telegram
- discord
```
The values in `support_platforms` must be keys from `ADAPTER_NAME_2_TYPE`. Currently supported:
- `aiocqhttp`
- `qq_official`
- `telegram`
- `wecom`
- `lark`
- `dingtalk`
- `discord`
- `slack`
- `kook`
- `vocechat`
- `weixin_official_account`
- `satori`
- `misskey`
- `line`
### Declare AstrBot Version Range (Optional)
You can add an `astrbot_version` field in `metadata.yaml` to declare the required AstrBot version range for your plugin. The format follows dependency specifiers in `pyproject.toml` (PEP 440), and must not include a `v` prefix.
```yaml
astrbot_version: ">=4.16,<5"
```
Examples:
- `>=4.17.0`
- `>=4.16,<5`
- `~=4.17`
If you only want to declare a minimum version, use:
- `>=4.17.0`
If the current AstrBot version does not satisfy this range, the plugin will be blocked from loading with a compatibility error.
In the WebUI installation flow, you can choose to "Ignore Warning and Install" to bypass this check.
### Debugging Plugins
AstrBot uses a runtime plugin injection mechanism. Therefore, when debugging plugins, you need to start the AstrBot main application.
You can use AstrBot's hot reload feature to streamline the development process.
After modifying the plugin code, you can find your plugin in the AstrBot WebUI's plugin management section, click the `...` button in the upper right corner, and select `Reload Plugin`.
If the plugin fails to load due to code errors or other reasons, you can also click **"Try one-click reload fix"** in the error prompt on the admin panel to reload it.
### Plugin Dependency Management
Currently, AstrBot manages plugin dependencies using pip's built-in `requirements.txt` file. If your plugin requires third-party libraries, please be sure to create a `requirements.txt` file in the plugin directory and list the dependencies used, to prevent Module Not Found errors when users install your plugin.
> For the complete format of `requirements.txt`, please refer to the [pip official documentation](https://pip.pypa.io/en/stable/reference/requirements-file-format/).
## Development Principles
Thank you for contributing to the AstrBot ecosystem. Please follow these principles when developing plugins, which are also good programming practices:
- Features must be tested.
- Include comprehensive comments.
- Store persistent data in the `data` directory, not in the plugin's own directory, to prevent data loss when updating/reinstalling the plugin.
- Implement robust error handling mechanisms; don't let a single error crash the plugin.
- Before committing, please use the [ruff](https://docs.astral.sh/ruff/) tool to format your code.
- Do not use the `requests` library for network requests; use asynchronous network request libraries such as `aiohttp` or `httpx`.
- If you're extending functionality for an existing plugin, please prioritize submitting a PR to that plugin rather than creating a separate one (unless the original plugin author has stopped maintaining it).
+9
View File
@@ -0,0 +1,9 @@
# Publishing Plugins to the Plugin Marketplace
After completing your plugin development, you can choose to publish it to the AstrBot Plugin Marketplace, allowing more users to benefit from your work.
AstrBot uses GitHub to host plugins, so you'll need to push your plugin code to the GitHub plugin repository you created earlier.
You can submit your plugin by visiting the [AstrBot Plugin Marketplace](https://plugins.astrbot.app). Once on the website, click the `+` button in the bottom-right corner, fill in the basic information, author details, repository information, and other required fields. Then click the `Submit to GITHUB` button. You will be redirected to the AstrBot repository's Issue submission page. Please verify that all information is correct, then click the `Create` button to complete the plugin publication process.
![fill out the form](https://files.astrbot.app/docs/source/images/plugin-publish/image.png)
+79
View File
@@ -0,0 +1,79 @@
# FAQ
## Dashboard Related
### Encountering 404 Error When Opening the Dashboard
Download `dist.zip` from the [release](https://github.com/AstrBotDevs/AstrBot/releases) page, extract it, and move it to `AstrBot/data`. If it still doesn't work, try restarting your computer (based on community feedback).
### Forgot Dashboard Password
If you forgot your AstrBot dashboard password, you can modify the `"dashboard"` field in the `AstrBot/data/cmd_config.json` configuration file, where `"username"` is your username and `"password"` is your password encrypted with MD5.
To modify your account credentials, follow these steps:
1. Modify the `"username"` field, keeping the `""` quotation marks. If you don't want to change the username, skip this step
2. Visit the website: [Online MD5 Generator](https://www.metools.info/code/c26.html)
3. Enter your new password in the input text box
4. Select MD5 encryption (32-bit), make sure to choose the 32-bit option
5. Paste the converted string into the configuration file, keeping the `""` quotation marks
## Bot Core Related
### How to Let AstrBot Control My Mac / Windows / Linux Computer?
1. In AstrBot WebUI's `Config -> General Config`, find `Use Computer Capabilities`, and select `local` for the runtime environment.
2. In `Config -> Other Config`, find `Admin ID List`, and add your user ID (you can get it through the `/sid` command).
> [!TIP]
> For security reasons, when runtime environment is set to `local`, AstrBot only allows AstrBot administrators to use computer capabilities by default.
> You can select `sandbox` for the runtime environment, which allows all users to use computer capabilities (in an isolated sandbox). For more details, see [AstrBot Sandbox Environment](/en/use/astrbot-agent-sandbox.md)
### Bot Cannot Chat in Group Conversations
1. In group chats, to prevent message flooding, the bot will not respond to every monitored message. Please try mentioning (@) the bot or using a wake word to chat, such as the default `/`, for example: `/hello`.
### No Permission to Execute Admin Commands
1. `/reset, /persona, /dashboard_update, /op, /deop, /wl, /dewl` are the default admin commands. You can use the `/sid` command to get a user's ID, then add it to the admin ID list in Settings -> Other Settings.
### Chinese Characters Garbled When Locally Rendering Markdown Images (t2i)
You can customize the font. See details -> [#957](https://github.com/AstrBotDevs/AstrBot/issues/957#issuecomment-2749981802)
Recommended font: [Maple Mono](https://github.com/subframe7536/maple-font).
### Cannot Parse API Returned Completion & LLM Returns `<empty content>`
This is because the provider's API returned empty text. Try the following steps:
1. Check if the API key is still valid
2. Check if the API call limit or quota has been reached
3. Check network connection
4. Try reset
5. Lower the maximum conversation count setting
6. Switch to another model from the same provider / a different provider
## Plugin Related
### Cannot Install Plugin
1. Plugins are installed via GitHub. Access to GitHub from mainland China can indeed be unstable. You can use a proxy, then go to Other Settings -> HTTP Proxy to configure it. Alternatively, download the plugin archive directly and upload it.
### Error `No module named 'xxx'` After Installing Plugin
![image](https://files.astrbot.app/docs/source/images/faq/image.png)
This is because the plugin's dependencies were not installed properly. Normally, AstrBot automatically installs plugin dependencies after installing the plugin, but installation may fail in the following situations:
1. Network issues preventing dependency downloads
2. Plugin author did not include a `requirements.txt` file
3. Python version incompatibility
Solution:
Based on the error message, refer to the plugin's README to manually install dependencies. You can install dependencies in the AstrBot WebUI under `Console` -> `Install Pip Package`.
![image](https://files.astrbot.app/docs/source/images/faq/image-1.png)
If you find that the plugin author did not include a `requirements.txt` file, please submit an issue in the plugin repository to remind the author to add it.
+31
View File
@@ -0,0 +1,31 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: >-
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px; margin-bottom: 16px;" width="250" height="55"/></a>
text: "Agentic AI assistant for personal and group chats"
tagline: Connect any IM / 1000+ plugins / General Agent Orchestration
actions:
- theme: brand
text: Quick Start
link: /en/what-is-astrbot
- theme: alt
text: GitHub Repository
link: https://github.com/AstrBotDevs/AstrBot
features:
- icon: ✨
title: Multi-Platform Support
details: Seamlessly supports multiple messaging platforms including QQ, WeCom, Telegram, Discord, and more with multi-instance deployment.
- icon: 😌
title: User-Friendly
details: Easy deployment via Docker or Windows one-click installer with no complex configuration required. Features a highly visual management dashboard.
- icon: 🧩
title: Highly Extensible
details: Built on event bus and pipeline architecture with full modularity. All features can be enabled or disabled, with comprehensive plugin development support.
- icon: 🌟
title: Large Language Models
details: Compatible with multiple model providers including OpenAI, Anthropic, Google, Ollama, Deepseek, and more, supporting diverse LLM integrations.
---
+31
View File
@@ -0,0 +1,31 @@
# 开源之夏 2025
**开源之夏**是由中国科学院软件研究所“开源软件供应链点亮计划”发起并长期支持的一项暑期开源活动,旨在鼓励在校学生积极参与开源软件的开发维护,培养和发掘更多优秀的开发者,促进优秀开源软件社区的蓬勃发展,助力开源软件供应链建设。具体活动信息请参考 [开源之夏官网](https://summer-ospp.ac.cn/)。
AstrBot 社区有幸作为开源社区参与了本次活动,下面列出了目前我们已经发布的项目,欢迎感兴趣的同学们参与。
## 插件数据存储逻辑优化
目前,AstrBot 插件系统在数据存储方面缺乏一致的架构。部分插件使用 SharedPreference 存储机制和 JSON 格式进行数据持久化。这种多样化的存储方式导致了存储逻辑的不统一,既影响了数据的安全性,也增加了插件间的兼容性问题。此外,缺乏标准化的接口使得插件的数据存储和访问方式各异,给系统的维护和扩展带来挑战。本项目旨在重构当前存储方案,引入更安全且高效的数据存储机制,并设计一个统一的插件数据接口模型,规范插件的数据存储与访问,提升系统的安全性、可扩展性和可维护性,为未来插件的开发与管理提供坚实基础。
**项目链接**[插件数据存储逻辑优化](https://summer-ospp.ac.cn/org/prodetail/253550342?lang=zh&list=pro)
**难度**:进阶
**导师**[Soulter](https://github.com/Soulter)
**期望完成时间**210 小时
**项目产出要求**
1. 设计并实现统一且高效的插件数据存储接口模型,规范插件的数据存储;
2. 重构当前 SharedPreference 的存储逻辑,采用更安全的存储方式;
3. 补充相关技术文档。
**项目技术要求**
1. 熟悉 Python、Javascript 语言及 asyncio 异步编程技术;
2. 熟悉 SQLite 等关系型数据库相关开发;
3. 熟悉 AstrBot 框架及插件开发。
**成果仓库**[https://github.com/AstrBotDevs/AstrBot](https://github.com/AstrBotDevs/AstrBot)
+28
View File
@@ -0,0 +1,28 @@
# Self-host the Text-to-Image Service
AstrBot uses [AstrBotDevs/astrbot-t2i-service](https://github.com/AstrBotDevs/astrbot-t2i-service) as the default text-to-image service. The default service endpoints are:
```plain
https://t2i.soulter.top/text2img
https://t2i.rcfortress.site/text2img
```
This interface can ensure normal response for most of the time. However, due to the deployment of servers in New York, the response speed may be slower in some areas.
> [!TIP]
> If you'd like to support us to help pay for server costs, please consider supporting us on [Afdian](https://afdian.com/a/astrbot_team).
You can choose to self-host the text-to-image service to improve response speed.
```bash
docker run -itd -p 8999:8999 soulter/astrbot-t2i-service:latest
```
After deployment, go to AstrBot Dashboard -> Config -> System, and change `Text-to-Image Service API Endpoint` to the URL you deployed (as shown below).
> If you deployed AstrBot using the Docker tutorial in this documentation, the URL should be `http://<t2i-service-container-name>:8999`.
> If you deployed on the same machine as AstrBot, the URL should be `http://localhost:8999`.
<img width="589" height="255" alt="image" src="https://github.com/user-attachments/assets/5ef09db2-1a33-440c-9986-c7b544325e34" />
+55
View File
@@ -0,0 +1,55 @@
# Connect to Lagrange
> [!TIP]
> - Please control message frequency responsibly. Sending messages too frequently may trigger risk control.
> - This project must not be used for illegal purposes.
> - For the latest deployment steps, always refer to the official [Lagrange Docs](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85).
## Download
Download the latest `Lagrange.OneBot` from [GitHub Releases](https://github.com/LagrangeDev/Lagrange.Core/releases).
- Windows: `Lagrange.OneBot_win-x64_xxxx`
- Linux x86_64: `Lagrange.OneBot_linux-x64_xxx`
- Linux ARM64: `Lagrange.OneBot_linux-arm64_xxx`
- macOS Apple Silicon: `Lagrange.OneBot_osx-arm64_xxx`
- macOS Intel: `Lagrange.OneBot_osx-x64_xxx`
## Deploy
Follow the official docs:
- Run guide: <https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E8%BF%90%E8%A1%8C>
- Config file guide: <https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6>
In your config file, add this under `Implementations`:
```json
{
"Type": "ReverseWebSocket",
"Host": "127.0.0.1",
"Port": 6199,
"Suffix": "/ws",
"ReconnectInterval": 5000,
"HeartBeatInterval": 5000,
"AccessToken": ""
}
```
Make sure `Suffix` is exactly `/ws`.
## Connect to AstrBot
### Configure `aiocqhttp` Adapter
1. Open AstrBot Dashboard.
2. Click `Bots` in the left sidebar.
3. Click `+ Create Bot`.
4. Select `aiocqhttp (OneBot v11)`.
Fill in:
- ID (`id`): any unique identifier.
- Enable (`enable`): checked.
- Reverse WebSocket host: your machine IP (usually `0.0.0.0`).
- Reverse WebSocket port: an available port, for example `6199`.
+141
View File
@@ -0,0 +1,141 @@
# Using NapCat
> [!TIP]
>
> - Please control usage frequency appropriately. Sending messages too frequently may be identified as abnormal behavior, increasing the risk of triggering risk control mechanisms.
> - This project is strictly prohibited from being used for any purpose that violates laws and regulations. If you intend to use AstrBot for illegal industries or activities, we **explicitly oppose and refuse** your use of this project.
> - AstrBot connects to the OneBot v11 protocol through the `aiocqhttp` adapter. OneBot v11 protocol is an open communication protocol and does not represent any specific software or service.
NapCat's GitHub Repository: [NapCat](https://github.com/NapNeko/NapCatQQ)
NapCat's Documentation: [NapCat Documentation](https://napcat.napneko.icu/)
NapCat provides multiple deployment methods, including Docker, Windows one-click installation packages, and more.
## Deploy via One-Click Script
This deployment method is recommended.
### Windows
Refer to this article: [NapCat.Shell - Windows Manual Start Tutorial](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B)
### Linux
Refer to this article: [NapCat.Installer - Linux One-Click Script (Supports Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)
> [!TIP]
> **Where to open Napcat WebUI**:
> The WebUI link will be displayed in napcat's logs.
>
> If napcat is deployed via Linux command line one-click deployment: `docker log <account>`.
>
> For Docker-deployed NapCat: `docker logs napcat`.
## Deploy via Docker Compose
> [!TIP]
> If deploying with Docker Compose, no configuration is needed on the NapCat side. Just log in via NapCat WebUI (running on port 6099) or `docker logs napcat`, enable the aiocqhttp adapter on the AstrBot side to connect, and you can directly implement normal receiving and sending of `voice data` and `file data`.
1. Download or copy the content of [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml)
2. Rename the downloaded file to `astrbot.yml`
3. Modify `astrbot.yml`, change `#- "6199:6199` to `- "6199:6199"`, remove the flag of "#"
4. Execute in the directory where the `astrbot.yml` file is located:
```bash
NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d
```
## Deploy via Docker
> [!TIP]
> If deploying with Docker, you will not be able to properly receive `voice data` and `file data`. This means voice-to-text and sandbox file input functions will not be available. You can receive text messages, image messages, and other types of messages.
This tutorial assumes you have Docker installed.
Execute the following command in the terminal for one-click deployment.
```bash
docker run -d \
-e NAPCAT_GID=$(id -g) \
-e NAPCAT_UID=$(id -u) \
-p 3000:3000 \
-p 3001:3001 \
-p 6099:6099 \
--name napcat \
--restart=always \
mlikiowa/napcat-docker:latest
```
After successful execution, you need to check the logs to get the login QR code and the management panel URL.
```bash
docker logs napcat
```
Please copy the management panel URL and open it in your browser.
Then use the account you want to log in with to scan the QR code that appears.
If there are no issues during the login stage, deployment is successful.
## Connect to AstrBot
## Configure aiocqhttp in AstrBot
1. Enter AstrBot's management panel
2. Click `Bots` in the left sidebar
3. Then in the interface on the right, click `+ Create Bot`
4. Select `OneBot v11`
Fill in the configuration items that appear:
- ID(id): Fill in arbitrarily, only used to distinguish different messaging platform instances.
- Enable: Check this.
- Reverse WebSocket Host Address: Please fill in your machine's IP address, generally fill in `0.0.0.0` directly
- Reverse WebSocket Port: Fill in a port, default is `6199`.
- Reverse Websocket Token: Only needs to be filled when a token is configured in NapCat's network settings.
Example image: (At the fastest, just check Enable, then save)
<img width="818" height="799" alt="xinjianya" src="https://github.com/user-attachments/assets/813ac338-2fd7-4add-bde4-8b0f6d0bda95" />
Click `Save`.
### Configure Administrator
After filling in, go to the `Configuration File` page, click the `Platform Configuration` tab, find `Administrator ID`, and fill in your account number (not the bot's account number).
Remember to click `Save` in the lower right corner, AstrBot will restart and apply the configuration.
### Add WebSocket Client in NapCat
Switch back to NapCat's management panel, click `Network Configuration->New->WebSockets Client`.
<img width="649" height="751" alt="jiaochenXJY" src="https://github.com/user-attachments/assets/5044f96a-a81f-407a-a3b1-0c518499eda4" />
In the newly opened window:
- Check `Enable`.
- Fill in `URL` with `ws://HostIP:Port/ws`. For example, `ws://localhost:6199/ws` or `ws://127.0.0.1:6199/ws`.
> [!IMPORTANT]
> 1. If deploying with Docker and both AstrBot and NapCat containers are connected to the same network, use `ws://astrbot:6199/ws` (refer to the Docker script in this documentation).
> 2. Due to Docker network isolation, when not on the same network, please use the internal network IP address or public network IP address ***(unsafe)*** to connect, i.e., `ws://(internal/public IP):6199/ws`.
- Message Format: `Array`
- Heartbeat Interval: `5000`
- Reconnection Interval: `5000`
> [!WARNING]
>
> 1. Remember to add `/ws` at the end!
> 2. The IP here cannot be `0.0.0.0`
Click `Save`.
Go to AstrBot WebUI `Console`, if you see the blue log ` aiocqhttp(OneBot v11) adapter connected.`, it means the connection is successful. If not, and after several seconds ` aiocqhttp adapter has been closed` appears, it indicates connection timeout (failed), please check if the configuration is correct.
## 🎉 All Done
At this point, your AstrBot and NapCat should be successfully connected! Use `private message` to send `/help` to the bot to check if the connection is successful.

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