Compare commits

...

115 Commits

Author SHA1 Message Date
Soulter 84994b5d98 chore: bump version to 4.18.3 2026-02-26 19:12:09 +08:00
圣达生物多 1554f71106 [bug]查看介入教程line前往错误界面的问题 (#5479)
Fixes #5478
2026-02-26 19:04:53 +08:00
Soulter 476c01469f fix(line): line adapter does not appear in the add platform dialog
fixes: #5477
2026-02-26 15:26:37 +08:00
Dt8333 10163ec78a chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474) 2026-02-26 14:19:52 +08:00
Waterwzy 98b89ebcc5 fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462)
* fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins

* fix:ruff format,apply bot suggestions

* Apply suggestion from @gemini-code-assist[bot]

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-26 10:02:43 +08:00
CAICAII 39b9e55434 perf: batch metadata query in KB retrieval to fix N+1 problem (#5463)
* perf: batch metadata query in KB retrieval to fix N+1 problem

Replace N sequential get_document_with_metadata() calls with a single
get_documents_with_metadata_batch() call using SQL IN clause.

Benchmark results (local SQLite):
- 10 docs: 10.67ms → 1.47ms (7.3x faster)
- 20 docs: 26.00ms → 2.68ms (9.7x faster)
- 50 docs: 63.87ms → 2.79ms (22.9x faster)

* refactor: use set[str] param type and chunk IN clause for SQLite safety

Address review feedback:
- Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion
- Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit
- Remove list() wrapping at call site, pass set directly
2026-02-26 09:59:37 +08:00
CCCCCCTV 3eb15089af fix(telegram): avoid duplicate message_thread_id in streaming (#5430) 2026-02-25 19:53:17 +08:00
Dt8333 c5b23d12a8 fix: 修复Pyright静态类型检查报错 (#5437)
* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整

* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持
2026-02-25 19:49:16 +08:00
exynos 69f2fb291a fix: cannot automatically get embedding dim when create embedding provider (#5442)
* fix(dashboard): 强化 API Key 复制临时节点清理逻辑

* fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度

* fix: normalize openai embedding base url and add hint key

* i18n: add embedding_api_base hint translations

* i18n: localize provider embedding/proxy metadata hints

* fix: show provider-specific embedding API Base URL hint as field subtitle

* fix(embedding): cap OpenAI detect_dim probes with early short-circuit

* fix(dashboard): return generic error on provider adapter import failure

* 回退检测逻辑
2026-02-25 19:48:03 +08:00
時壹 78660da995 fix: clear markdown field when sending media messages via QQ Official Platform (#5445)
* fix: clear markdown field when sending media messages via QQ Official API

* refactor: use pop() to remove markdown key instead of setting None
2026-02-25 19:45:27 +08:00
Soulter c951b14aa2 feat: add useExtensionPage composable for managing plugin extensions
- Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states.
- Added support for conflict checking, plugin installation, and custom source management.
- Integrated search and filtering capabilities for plugins in the market.
- Enhanced user experience with dialogs for confirmations and notifications.
- Included pagination and sorting features for better plugin visibility.
2026-02-25 19:42:51 +08:00
Soulter c384439b44 perf(cron): enhance future task session isolation
fixes: #5392
2026-02-25 16:32:09 +08:00
鸦羽 87d2750ff8 Merge pull request #5440 from Raven95676/fix/persona-loss
fix(persona): preserve conversation persona_id and unify session/conversation resolution
2026-02-25 16:11:19 +08:00
Luna_Dol 6d76d55452 fix: ensure tool call/response pairing in context truncation (#5417)
* fix: ensure tool call/response pairing in context truncation

* refactor: simplify fix_messages to single-pass state machine
2026-02-25 15:21:30 +08:00
Raven95676 d80598b9c3 fix(persona): enhance persona resolution logic for conversations and sessions 2026-02-25 15:14:46 +08:00
letr c7d318304b Fix: GitHub proxy not displaying correctly in WebUI (#5438)
* fix(dashboard): preserve custom GitHub proxy setting on reload

* fix(dashboard): keep github proxy selection persisted in settings
2026-02-25 14:54:54 +08:00
exynos bcdbc15635 fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439) 2026-02-25 14:54:06 +08:00
Raven95676 4749159bb9 fix(conversation): retain existing persona_id when updating conversation 2026-02-25 14:48:46 +08:00
エイカク 5530a2260a feat(dashboard): add generic desktop app updater bridge (#5424)
* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings
2026-02-25 10:01:13 +09:00
Soulter c24de24ca4 chore: ruff format 2026-02-24 23:12:18 +08:00
Yunhao Cao b54b4c79ed fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389) 2026-02-24 23:11:56 +08:00
Soulter c6cc7aae84 chore: bump version to 4.18.2 2026-02-24 23:08:53 +08:00
Soulter 84cd209074 chore: bump version to 4.18.2 2026-02-24 22:48:27 +08:00
Soulter afda44fbe3 chore: bump version to 4.18.2 2026-02-24 22:44:35 +08:00
Soulter f5d3b93437 fix(context): improve logging for platform not found in session 2026-02-24 22:37:51 +08:00
Soulter 069a3628fa fix(context): log warning when platform not found for session 2026-02-24 22:37:10 +08:00
氕氙 c81ef2672a fix: pass embedding dimensions to provider apis (#5411) 2026-02-24 22:09:44 +08:00
Soulter a5ae27cae0 fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412) 2026-02-24 22:07:42 +08:00
Helian Nuits 73faaf6577 i18n(SubAgentPage): complete internationalization for subagent orchestration page (#5400)
* i18n: complete internationalization for subagent orchestration page

- Replace hardcoded English strings in [SubAgentPage.vue] with i18n keys.
- Update `en-US` and `zh-CN` locales with missing hints, validation messages, and empty state translations.
- Fix translation typos and improve consistency across the SubAgent orchestration UI.

* fix(bug_risk): 避免在模板中的翻译调用上使用 || 'Close' 作为回退值。
2026-02-24 21:04:01 +08:00
Helian Nuits 29dbd085d4 fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 (#5391)
* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性

原因 (Necessity):
1. 内核一致性:AstrBot 内核的 Record 和 Video 组件均具备识别 `file:///` 协议头的逻辑,但 File 组件此前缺失此功能,导致行为不统一。
2. OneBot 协议合规:OneBot 11 标准要求本地文件路径必须使用 `file:///` 协议头。此前驱动层未对裸路径进行自动转换,导致发送本地文件时常触发 retcode 1200 (识别URL失败) 错误。
3. 容器环境适配:在 Docker 等路径隔离环境下,裸路径更容易因驱动或协议端的解析歧义而失效。

更改 (Changes):
- [astrbot/core/message/components.py]:
  - 在 File.get_file() 中增加对 `file:///` 前缀的识别与剥离逻辑,使其与 Record/Video 组件行为对齐。
- [astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py]:
  - 在发送文件前增加自动修正逻辑:若路径为绝对路径且未包含协议头,驱动层将自动补全 `file:///` 前缀。
  - 对 http、base64 及已有协议头,确保不干扰原有的正常传输逻辑。

影响 (Impact):
- 以完全兼容的方式增强了文件发送的鲁棒性。
- 解决了插件在发送日志等本地生成的压缩包时,因路径格式不规范导致的发送失败问题。

* refactor(core): 根据 cr 建议,规范化文件 URI 生成与解析逻辑,优化跨平台兼容性

原因 (Necessity):
1. 修复原生路径与 URI 转换在 Windows 下的不对称问题。
2. 规范化 file: 协议头处理,确保符合 RFC 标准并能在 Linux/Windows 间稳健切换。
3. 增强协议判定准确度,防止对普通绝对路径的误处理。

更改 (Changes):
- [astrbot/core/platform/sources/aiocqhttp]:
  - 弃用手动拼接,改用 `pathlib.Path.as_uri()` 生成标准 URI。
  - 将协议检测逻辑从前缀匹配优化为包含性检测 ("://")。
- [astrbot/core/message/components]:
  - 重构 `File.get_file` 解析逻辑,支持对称处理 2/3 斜杠格式。
  - 针对 Windows 环境增加了对 `file:///C:/` 格式的自动修正,避免 `os.path` 识别失效。
- [data/plugins/astrbot_plugin_logplus]:
  - 在直接 API 调用中同步应用 URI 规范化处理。

影响 (Impact):
- 解决 Docker 环境中因路径不规范导致的 "识别URL失败" 报错。
- 提升了本体框架在 Windows 系统下的文件操作鲁棒性。
2026-02-24 21:03:06 +08:00
Axi404 00b011809a fix: enforce admin guard for sandbox file transfer tools (#5402)
* fix: enforce admin guard for sandbox file transfer tools

* refactor: deduplicate computer tools admin permission checks

* fix: add missing space in permission error message
2026-02-24 20:59:44 +08:00
Axi404 0b46ca7ff3 feat: enable computer-use tools for subagent handoff (#5399) 2026-02-24 16:32:12 +08:00
whatevertogo 9294b44831 fix: resolve pipeline and star import cycles (#5353)
* fix: resolve pipeline and star import cycles

- Add bootstrap.py and stage_order.py to break circular dependencies
- Export Context, PluginManager, StarTools from star module
- Update pipeline __init__ to defer imports
- Split pipeline initialization into separate bootstrap module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add logging for get_config() failure in Star class

* fix: reorder logger initialization in base.py

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:53:29 +08:00
Soulter 80fd51119b feat: add support for showing tool call results in agent execution (#5388)
closes: #5329
2026-02-24 00:46:45 +08:00
whatevertogo 5af5ad9e36 test: add comprehensive tests for message event handling (#5355)
* test: add comprehensive tests for message event handling

- Add AstrMessageEvent unit tests (688 lines)
- Add AstrBotMessage unit tests
- Enhance smoke tests with message event scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve message type handling and add defensive tests

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:36:39 +08:00
whatevertogo 7b731ebda8 test: enhance test framework with comprehensive fixtures and mocks (#5354)
* test: enhance test framework with comprehensive fixtures and mocks

- Add shared mock builders for aiocqhttp, discord, telegram
- Add test helpers for platform configs and mock objects
- Expand conftest.py with test profile support
- Update coverage test workflow configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数

* fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:35:15 +08:00
PyuraMazo 28bfb3b8b2 feat: add plugin load&unload hook (#5331)
* 添加了插件的加载完成和卸载完成的钩子事件

* 添加了插件的加载完成和卸载完成的钩子事件

* format code with ruff

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-23 23:13:41 +08:00
tangsenfei 351895ae66 fix: 处理配置文件中的 UTF-8 BOM 编码问题 (#5376)
* fix(config): handle UTF-8 BOM in configuration file loading

Problem:
On Windows, some text editors (like Notepad) automatically add UTF-8 BOM
to JSON files when saving. This causes json.decoder.JSONDecodeError:
"Unexpected UTF-8 BOM" and AstrBot fails to start when cmd_config.json
contains BOM.

Solution:
Add defensive check to strip UTF-8 BOM (\ufeff) if present before
parsing JSON configuration file.

Impact:
- Improves robustness and cross-platform compatibility
- No breaking changes to existing functionality
- Fixes startup failure when configuration file has UTF-8 BOM encoding

Relates-to: Windows editor compatibility issues

* style: fix code formatting with ruff

Fix single quote to double quote to comply with project code style.
2026-02-23 22:27:56 +08:00
hanbings c1009adf52 fix(chatui): add copy rollback path and error message. (#5352)
* fix(chatui): add copy rollback path and error message.

* fix(chatui): fixed textarea leak in the copy button.

* fix(chatui): use color styles from the component library.
2026-02-23 22:24:41 +08:00
Waterwzy ecaec41208 feat: add hot reload when failed to load plugins (#5334)
* feat:add hot reload when failed to load plugins

* apply bot suggestions
2026-02-23 22:17:48 +08:00
Chen 997b51102b feat: add image urls / paths supports for subagent (#5348)
* fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081)

* feat:为子代理增加远程图片URL参数支持

* fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-23 22:16:14 +08:00
Helian Nuits c5bd074c28 chore(README): updated with README.md (#5375)
* chore(README): updated with README.md

* Update README_fr.md

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* Update README_zh-TW.md

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-02-23 22:05:22 +08:00
鸦羽 4c09ed3c09 fix(plugin): update plugin directory handling for reserved plugins (#5369)
* fix(plugin): update plugin directory handling for reserved plugins

* fix(plugin): add warning logs for missing plugin name, object, directory, and changelog
2026-02-23 22:04:47 +08:00
Soulter a56e43d17e fix: chatui cannot persist file segment (#5386) 2026-02-23 22:02:49 +08:00
Soulter e357d9de74 feat: add stop functionality for active agent sessions and improve handling of stop requests (#5380)
* feat: add stop functionality for active agent sessions and improve handling of stop requests

* feat: update stop button icon and tooltip in ChatInput component

* fix: correct indentation in tool call handling within ChatRoute class
2026-02-23 20:21:30 +08:00
エイカク 94736ff199 feat(dashboard): make release redirect base URL configurable (#5330)
* feat(dashboard): make desktop release base URL configurable

* refactor(dashboard): use generic release base URL env with upstream default

* fix(dashboard): guard release base URL normalization when env is unset

* refactor(dashboard): use generic release URL helpers and avoid latest suffix duplication
2026-02-22 20:23:32 +09:00
Soulter aff92a48bf fix: remove changelogs directory from .dockerignore 2026-02-22 17:15:07 +08:00
Soulter d0998a9dfb fix: remove hard-coded 6s timeout from tavily request 2026-02-22 16:58:49 +08:00
Soulter 3678688433 chore: ruff format 2026-02-22 16:51:46 +08:00
Lovely Moe Moli 0c03177840 fix: 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 (#5316) 2026-02-22 16:45:28 +08:00
Soulter 20ff719c00 cho 2026-02-22 16:43:00 +08:00
Gao Jinzhe 8a8ec492d7 feat: supports spawn subagent as a background task that not block the main agent workflow (#5081)
* feat:为subagent添加后台任务参数

* ruff

* fix: update terminology from 'handoff mission' to 'background task' and refactor related logic

* fix: update terminology from 'background_mission' to 'background_task' in HandoffTool and related logic

* fix(HandoffTool): update background_task description for clarity on usage

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-22 13:57:42 +08:00
Helian Nuits 02c1443dd1 fix: 修复新版本插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 (#5309)
* fix(ExtensionCard): 解决插件卡片大小不统一的问题

* fix(MarketPluginCard): 解决插件市场不加载插件的问题 (#5303)
2026-02-22 10:32:39 +08:00
Helian Nuits 79301f192c fix(SubAgentPage): 当中间的介绍文本非常长时,Flex 布局会自动挤压右侧的控制按钮区域 (#5306) 2026-02-22 10:31:49 +08:00
Soulter 4b2c854c42 chore: bump version to 4.18.0 2026-02-22 00:17:13 +08:00
Li-shi-ling d02ee7be8b fix: 修复新建对话时因缺少会话ID导致配置绑定失败的问题 (#5292)
* fix:尝试修改

* fix:添加详细日志

* fix:进行详细修改,并添加日志

* fix:删除所有日志

* fix: 增加安全访问函数

- 给 localStorage 访问加了 try/catch + 可用性判断:dashboard/src/utils/chatConfigBinding.ts:13
- 新增 getFromLocalStorage/setToLocalStorage(在受限存储/无痕模式下异常时回退/忽略)
- getStoredDashboardUsername() / getStoredSelectedChatConfigId() 改为走安全读取:dashboard/src/utils/chatConfigBinding.ts:36       - 新增 setStoredSelectedChatConfigId(),写入失败静默忽略:dashboard/src/utils/chatConfigBinding.ts:44
- 把 ConfigSelector.vue 里直接 localStorage.getItem/setItem 全部替换为上述安全方法:dashboard/src/components/chat/ConfigSelector.vue:81
- 已重新跑过 pnpm run typecheck,通过。

* rm:删除个人用的文档文件

* Revert "rm:删除个人用的文档文件"

This reverts commit 0fceee0543.

* rm:删除个人用的文档文件

* rm:删除个人用的文档文件
2026-02-21 23:50:13 +08:00
Soulter dbeadb6833 refactor: remove Anthropic OAuth provider implementation and related metadata overrides 2026-02-21 23:40:04 +08:00
evpeople 478cc32de1 Feat/telegram command alias register #5233 (#5234)
* feat: support registering command aliases for Telegram

Now when registering commands with aliases, all aliases will be
registered as Telegram bot commands in addition to the main command.

Example:
    @register_command(command_name="draw", alias={"画", "gen"})
Now /draw, /画, and /gen will all appear in the Telegram command menu.

* feat(telegram): add duplicate command name warning when registering commands

Log a warning when duplicate command names are detected during Telegram
command registration to help identify configuration conflicts.
2026-02-21 23:30:46 +08:00
Minidoracat 7b302445c2 feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209)
* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support

* fix: add defensive guard for metadata overrides and align budget condition with docs

* refactor: adopt sourcery-ai suggestions for OAuth provider

- Use use_api_key=False in OAuth subclass to avoid redundant
  API-key client construction before replacing with auth_token client
- Generalize metadata override helper to merge all dict keys
  instead of only handling 'limit', improving extensibility
2026-02-21 23:29:15 +08:00
エイカク ae839ef6d8 更新readme文档,补充桌面app说明,并向前移动位置 (#5297)
* docs: update desktop deployment section in README

* docs: refine desktop and launcher deployment descriptions

* Update README.md
2026-02-22 00:26:29 +09:00
Lovely Moe Moli 144a53f4b3 fix: qq official guild message send error (#5287)
* fix: qq official guild message send error

* Update astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

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

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-21 17:24:25 +08:00
Helian Nuits fa1d1e6034 feat(dashboard): improve plugin platform support display and mobile accessibility (#5271)
* feat(dashboard): improve plugin platform support display and mobile accessibility

- Replace hover-based tooltips with interactive click menus for platform support information.
- Fix mobile touch issues by introducing explicit state control for status capsules.
- Enhance UI aesthetics with platform-specific icons and a structured vertical list layout.
- Add dynamic chevron icons to provide clear visual cues for expandable content.

* refactor(dashboard): refactor market card with computed properties for performance

* refactor(dashboard): unify plugin platform support UI with new reusable chip component

- Create shared 'PluginPlatformChip' component to encapsulate platform meta display.
- Fix mobile interaction bugs by simplifying menu triggers and event handling.
- Add stacked platform icon previews and dynamic chevron indicators within capsules.
- Improve information hierarchy using structured vertical lists for platform details.
- Optimize rendering efficiency with computed properties across both card views.
2026-02-21 17:22:22 +08:00
Soulter a404436f2c feat: astrbot http api (#5280)
* feat: astrbot http api

* Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: improve error handling for missing attachment path in file upload

* feat: implement paginated retrieval of platform sessions for creators

* feat: refactor attachment directory handling in ChatRoute

* feat: update API endpoint paths for file and message handling

* feat: add documentation link to API key management section in settings

* feat: update API key scopes and related configurations in API routes and tests

* feat: enhance API key expiration options and add warning for permanent keys

* feat: add UTC normalization and serialization for API key timestamps

* feat: implement chat session management and validation for usernames

* feat: ignore session_id type chunks in message processing

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-21 17:20:26 +08:00
香草味的纳西妲喵 bcb12a0717 fix: update contributor avatar image URL to include max size and columns (#5268) 2026-02-21 00:38:11 +08:00
エイカク 5d0fc8ac7a refactor(dashboard): replace legacy isElectron bridge fields with isDesktop (#5269)
* refactor dashboard desktop bridge fields from isElectron to isDesktop

* refactor dashboard runtime detection into shared helper
2026-02-21 01:35:23 +09:00
Soulter a4d37e2c20 chore: ruff format 2026-02-20 23:06:37 +08:00
Soulter c599fb75ed feat: add OpenRouter provider support and icon 2026-02-20 22:57:20 +08:00
Soulter e7e0f84edf chore: bump vertion to 4.17.6 2026-02-20 18:40:45 +08:00
Soulter e19a282c59 fix: streamline error response for empty new username and password in account edit 2026-02-20 18:35:26 +08:00
Raven95676 fbc8667968 fix: simplify error messages for account edit validation 2026-02-20 16:27:28 +08:00
Soulter cda49c3a9a fix: remove additionalProperties from tool schema properties (#5253)
fixes: #5217
2026-02-20 16:13:20 +08:00
Soulter 4be1027444 fix: update tool status display and add localization for inactive tools 2026-02-20 16:01:55 +08:00
Soulter 46152d3faf fix: enhance PersonaForm layout and improve tool selection display 2026-02-20 15:54:06 +08:00
Soulter ed4cacfffb fix: all mcp tools exposed to main agent (#5252) 2026-02-20 15:40:13 +08:00
Soulter 52d1979937 chore: remove outdated heihe.md documentation file 2026-02-20 14:47:06 +08:00
NayukiMeko b30cb12133 fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250)
* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题

修复 _normalize_content 函数未处理 dict 类型 content 的问题。
当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时,
现在会正确提取 text 字段而非直接转为字符串。

同时改进 fallback 行为,对 None 值返回空字符串。

Fixes #5244

* Update warning message for unexpected dict format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-20 13:48:41 +08:00
whatevertogo 31d4e304fc feat: add password confirmation when changing password (#5247)
* feat: add password confirmation when changing password

Fixes #5177

Adds a password confirmation field to prevent accidental password typos.

Changes:
- Backend: validate confirm_password matches new_password
- Frontend: add confirmation input with validation
- i18n: add labels and error messages for password mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): improve error message for password confirmation mismatch

* fix(auth): update password hashing logic and improve confirmation validation

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 13:41:13 +08:00
Soulter 9a7a594cb5 feat: add support for plugin astrbot-version and platform requirement checks (#5235)
* feat: add support for plugin astrbot-version and platform requirement checks

* fix: remove unsupported platform and version constraints from metadata.yaml

* fix: remove restriction on 'v' in astrbot_version specification format

* ruff format
2026-02-20 13:35:45 +08:00
SnowNightt e469178a6b Feat/config leave confirm (#5249)
* feat: 配置文件增加未保存提示弹窗

* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗
2026-02-20 12:55:21 +08:00
Soulter 0a517980b7 fix: update feature request template for clarity and consistency in English and Chinese 2026-02-20 12:07:42 +08:00
エイカク 9c691b2266 chore: remove Electron desktop pipeline and switch to tauri repo (#5226)
* ci: remove Electron desktop build from release pipeline

* chore: remove electron desktop and switch to tauri release trigger

* ci: remove desktop workflow dispatch trigger

* refactor: migrate data paths to astrbot_path helpers

* fix: point desktop update prompt to AstrBot-desktop releases
2026-02-19 23:04:18 +09:00
雪語 3597726aad fix(core): terminate active events on reset/new/del to prevent stale responses (#5225)
* fix(core): terminate active events on reset/new/del to prevent stale responses

Closes #5222

* style: fix import sorting in scheduler.py
2026-02-19 19:26:47 +08:00
Soulter a4a37c268d docs: update related repo links 2026-02-19 18:11:07 +08:00
NanoRocky 651a0645c5 fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208)
* Fix Register_Stage

· 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。
· 顺带补全其它消息类型判断。
Co-authored-by: Pizero <zhaory200707@outlook.com>

* Fix formatting and comments in stage.py

* Format stage.py

---------

Co-authored-by: Pizero <zhaory200707@outlook.com>
2026-02-19 17:47:08 +08:00
Dream Tokenizer bf3fa3e918 fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224)
* 修复wechat official 被动回复功能

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-19 17:42:38 +08:00
Soulter 3b2ce9f500 feat: add admin permission checks for Python and Shell execution (#5214) 2026-02-19 01:48:48 +08:00
Soulter 20d6ff4620 chore: bump version to 4.17.5 2026-02-18 22:04:43 +08:00
Chiu Chun-Hsien a2b61e2ab8 refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204)
- Add _send_voice_with_fallback helper to deduplicate voice forbidden handling
- Catch telegram.error.BadRequest instead of bare Exception with string matching
- Add text field to Record component to preserve TTS source text
- Store original text in Record during TTS conversion for use as document caption
- Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings
2026-02-18 21:45:19 +08:00
sanyekana c6289d8f75 feat(core): add plugin error hook for custom error routing (#5192)
* feat(core): add plugin error hook for custom error routing

* fix(core): align plugin error suppression with event stop state
2026-02-18 21:38:27 +08:00
Soulter 567390e27c feat: add LINE support to multiple language README files 2026-02-18 21:35:27 +08:00
Soulter 0c0f8bf484 chore: ruff format 2026-02-18 18:22:06 +08:00
Soulter ae0a9cb591 docs: update readme 2026-02-18 18:20:08 +08:00
Soulter 3f4d7255a0 feat: supports aihubmix 2026-02-18 18:11:13 +08:00
Soulter b8d2499475 feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190)
* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage

* feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events
2026-02-18 17:29:04 +08:00
SnowNightt 8cb26d886f fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174) 2026-02-18 16:33:18 +08:00
時壹 3ca8dd204f fix: prevent duplicate error message when all LLM providers fail (#5183) 2026-02-18 16:29:35 +08:00
Soulter 3476afce41 feat: supports send markdown message in qqofficial (#5173)
* feat: supports send markdown message in qqofficial

closes: #1093 #918 #4180 #4264

* ruff format
2026-02-18 00:35:52 +08:00
Soulter 9b0e24ec49 chore: bump version to 4.17.4 2026-02-17 21:19:53 +08:00
Soulter 92d71fffe9 feat: add announcement section to WelcomePage and localize announcement title 2026-02-17 21:15:12 +08:00
Soulter 80c22f4f72 feat: add FAQ link to vertical sidebar and update navigation for localization 2026-02-17 21:01:29 +08:00
Soulter 6e22d266dd feat: implement search functionality in configuration components and update UI (#5168) 2026-02-17 20:47:24 +08:00
Soulter 4c285fb521 feat: add unsaved changes notice to configuration page and update messages 2026-02-17 20:32:25 +08:00
Helian Nuits 51c3521aaa ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)
- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
    1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
    2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
    3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。
2026-02-17 18:58:05 +08:00
Soulter 32112a3326 feat: enhance PersonaForm component with responsive design and improved styling (#5162)
fix: #5159
2026-02-17 18:46:52 +08:00
Soulter f22221f781 fix: improve permission denied message for local execution in Python and shell tools 2026-02-17 18:02:41 +08:00
Soulter 4250d997b3 feat: enhance provider sources panel with styled menu and mobile support 2026-02-17 16:14:35 +08:00
Soulter 153d8cef6b feat: add NVIDIA provider template (#5157)
fixes: #5156
2026-02-17 16:08:35 +08:00
Soulter c9cdf47603 chore: ruff format 2026-02-17 14:33:27 +08:00
Soulter 55ac878648 chore: bump version to 4.17.3 2026-02-17 14:09:10 +08:00
Soulter 60abddada3 fix: enhance handle_result to support event context and webchat image sending 2026-02-17 14:03:29 +08:00
Soulter bbc583cc8d fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155) 2026-02-17 14:01:31 +08:00
Soulter 7906030037 fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154) 2026-02-17 13:51:25 +08:00
エイカク 06b385697d fix(desktop): include runtime deps for builtin plugins in backend build (#5146) 2026-02-17 11:43:19 +09:00
Raven95676 059008a903 fix: prevent updates for AstrBot launched via launcher 2026-02-17 09:33:45 +08:00
Soulter 97c9e95211 chore: ruff format 2026-02-17 02:31:38 +08:00
211 changed files with 13649 additions and 8613 deletions
-1
View File
@@ -17,7 +17,6 @@ ENV/
.conda/
dashboard/
data/
changelogs/
tests/
.ruff_cache/
.astrbot
+12 -14
View File
@@ -1,42 +1,40 @@
name: '🎉 功能建议'
name: '🎉 Feature Request / 功能建议'
title: "[Feature]"
description: 提交建议帮助我们改进。
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
labels: [ "enhancement" ]
body:
- type: markdown
attributes:
value: |
感谢您抽出时间提出新功能建议,请准确解释您的想法。
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
- type: textarea
attributes:
label: 描述
description: 简短描述您的功能建议
label: Description / 描述
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
- type: textarea
attributes:
label: 使用场景
description: 你想要发生什么?
placeholder: >
一个清晰且具体的描述这个功能的使用场景。
label: Use Case / 使用场景
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
- type: checkboxes
attributes:
label: 愿意提交PR吗?
label: Willing to Submit PR? / 是否愿意提交PR
description: >
这不是必的,但我们欢迎您的贡献。
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
options:
- label: 是的, 我愿意提交PR!
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR
- type: checkboxes
attributes:
label: Code of Conduct
options:
- label: >
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
required: true
- type: markdown
attributes:
value: "感谢您填写我们的表单!"
value: "Thank you for filling out our form!"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.12'
- name: Install UV
run: pip install uv
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
mkdir -p data/temp
export TESTING=true
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
- name: Upload results to Codecov
uses: codecov/codecov-action@v5
-165
View File
@@ -102,170 +102,11 @@ jobs:
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-24.04
os: linux
arch: amd64
- name: linux-arm64
runner: ubuntu-24.04-arm
os: linux
arch: arm64
- name: windows-x64
runner: windows-2022
os: win
arch: amd64
- name: windows-arm64
runner: windows-11-arm
os: win
arch: arm64
- name: macos-x64
runner: macos-15-intel
os: mac
arch: amd64
- name: macos-arm64
runner: macos-15
os: mac
arch: arm64
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup uv
uses: astral-sh/setup-uv@v7
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
desktop/pnpm-lock.yaml
- name: Prepare OpenSSL for Windows ARM64
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
shell: pwsh
run: |
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Install dependencies
shell: bash
run: |
uv sync
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir desktop install --frozen-lockfile
- name: Build desktop package
shell: bash
run: |
pnpm --dir dashboard run build
pnpm --dir desktop run build:webui
pnpm --dir desktop run build:backend
pnpm --dir desktop run sync:version
pnpm --dir desktop exec electron-builder --publish never
- name: Normalize artifact names
shell: bash
env:
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
run: |
shopt -s nullglob
out_dir="desktop/dist/release"
mkdir -p "$out_dir"
files=(
desktop/dist/*.AppImage
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
)
if [ ${#files[@]} -eq 0 ]; then
echo "No desktop artifacts found to rename." >&2
exit 1
fi
for src in "${files[@]}"; do
file="$(basename "$src")"
case "$file" in
*.AppImage)
dest="$out_dir/${NAME_PREFIX}.AppImage"
;;
*.dmg)
dest="$out_dir/${NAME_PREFIX}.dmg"
;;
*.exe)
dest="$out_dir/${NAME_PREFIX}.exe"
;;
*.zip)
dest="$out_dir/${NAME_PREFIX}.zip"
;;
*)
continue
;;
esac
cp "$src" "$dest"
done
ls -la "$out_dir"
- name: Upload desktop artifacts
uses: actions/upload-artifact@v6
with:
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
if-no-files-found: error
path: desktop/dist/release/*
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-24.04
needs:
- build-dashboard
- build-desktop
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -296,12 +137,6 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Download desktop artifacts
uses: actions/download-artifact@v7
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Resolve release notes
id: notes
-7
View File
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
dashboard/node_modules/
dashboard/dist/
.pnpm-store/
desktop/node_modules/
desktop/dist/
desktop/out/
desktop/resources/backend/astrbot-backend*
desktop/resources/backend/*.exe
desktop/resources/webui/*
desktop/resources/.pyinstaller/
package-lock.json
yarn.lock
+24 -10
View File
@@ -43,7 +43,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
5. 📦 插件扩展,已有近 800 个插件可一键安装。
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
7. 💻 WebUI 支持。
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
@@ -56,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
<th>💙 角色扮演 & 情感陪伴</th>
<th>✨ 主动式 Agent</th>
<th>🚀 通用 Agentic 能力</th>
<th>🧩 900+ 社区插件</th>
<th>🧩 1000+ 社区插件</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -81,9 +81,15 @@ uv tool install astrbot
astrbot
```
#### 桌面应用部署(Tauri
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
#### 启动器一键部署(AstrBot Launcher
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
#### 宝塔面板部署
@@ -146,15 +152,12 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### 桌面端 Electron 打包
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
## 支持的消息平台
**官方维护**
- QQ (官方平台 & OneBot)
- QQ
- OneBot v11 协议实现
- Telegram
- 企微应用 & 企微智能机器人
- 微信客服 & 微信公众号
@@ -162,10 +165,10 @@ paru -S astrbot-git
- 钉钉
- Slack
- Discord
- LINE
- Satori
- Misskey
- Whatsapp (将支持)
- LINE (将支持)
**社区维护**
@@ -185,6 +188,7 @@ paru -S astrbot-git
- DeepSeek
- Ollama (本地部署)
- LM Studio (本地部署)
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小马算力](https://www.tokenpony.cn/3YPyf)
@@ -260,13 +264,23 @@ pre-commit install
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
此外,本项目的诞生离不开以下开源项目的帮助:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
开源项目友情链接:
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
## ⭐ Star History
> [!TIP]
+15 -19
View File
@@ -37,7 +37,7 @@
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8)
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## Key Features
@@ -45,7 +45,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
7. 💻 WebUI Support.
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
@@ -58,7 +58,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
<th>💙 Role-playing & Emotional Companionship</th>
<th>✨ Proactive Agent</th>
<th>🚀 General Agentic Capabilities</th>
<th>🧩 900+ Community Plugins</th>
<th>🧩 1000+ Community Plugins</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -93,6 +93,16 @@ yay -S astrbot-git
paru -S astrbot-git
```
#### Desktop Application (Tauri)
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
#### AstrBot Launcher
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
#### BT-Panel Deployment
AstrBot has partnered with BT-Panel and is now available in their marketplace.
@@ -144,20 +154,6 @@ uv run main.py
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
#### System Package Manager Installation
##### Arch Linux
```bash
yay -S astrbot-git
# or use paru
paru -S astrbot-git
```
#### Desktop Electron Build
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
## Supported Messaging Platforms
**Officially Maintained**
@@ -172,8 +168,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Coming Soon)
- LINE (Coming Soon)
**Community Maintained**
@@ -268,7 +264,7 @@ pre-commit install
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
+16 -16
View File
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div>
@@ -37,7 +37,7 @@
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## Fonctionnalités principales
@@ -45,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues.
3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc.
4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
5. 📦 Extension par plugins, avec ps de 800 plugins déjà disponibles pour une installation en un clic.
5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic.
6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session.
7. 💻 Support WebUI.
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
@@ -58,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
<th>💙 Jeux de rôle & Accompagnement émotionnel</th>
<th>✨ Agent proactif</th>
<th>🚀 Capacités agentiques générales</th>
<th>🧩 900+ Plugins de communauté</th>
<th>🧩 1000+ Plugins de communauté</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,15 +83,15 @@ uv tool install astrbot
astrbot
```
#### Installation via le gestionnaire de paquets du système
#### Application de bureau (Tauri)
##### Arch Linux
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
```bash
yay -S astrbot-git
# ou utiliser paru
paru -S astrbot-git
```
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs.
#### Déploiement en un clic avec le lanceur (AstrBot Launcher)
Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases.
#### Déploiement BT-Panel
@@ -144,13 +144,13 @@ uv run main.py
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
#### Установка через системный пакетный менеджер
#### Installation via le gestionnaire de paquets du système
##### Arch Linux
```bash
yay -S astrbot-git
# или используйте paru
# ou utiliser paru
paru -S astrbot-git
```
@@ -168,8 +168,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Bientôt disponible)
- LINE (Bientôt disponible)
**Maintenues par la communauté**
@@ -262,7 +262,7 @@ pre-commit install
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
+16 -16
View File
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div>
@@ -37,7 +37,7 @@
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## 主な機能
@@ -45,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
7. 💻 WebUI 対応。
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
@@ -58,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
<th>💙 ロールプレイ & 感情的な対話</th>
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
<th>🚀 汎用 エージェント的能力</th>
<th>🧩 900+ コミュニティプラグイン</th>
<th>🧩 1000+ コミュニティプラグイン</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,15 +83,15 @@ uv tool install astrbot
astrbot
```
#### システムパッケージマネージャーでのインストール
#### デスクトップアプリのデプロイ(Tauri)
##### Arch Linux
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
```bash
yay -S astrbot-git
# または paru を使用
paru -S astrbot-git
```
マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
#### ランチャーによるワンクリックデプロイ(AstrBot Launcher
迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
#### 宝塔パネルデプロイ
@@ -144,13 +144,13 @@ uv run main.py
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
#### Установка через системный пакетный менеджер
#### システムパッケージマネージャーでのインストール
##### Arch Linux
```bash
yay -S astrbot-git
# или используйте paru
# または paru を使用
paru -S astrbot-git
```
@@ -168,8 +168,8 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (近日対応予定)
- LINE (近日対応予定)
**コミュニティメンテナンス**
@@ -263,7 +263,7 @@ pre-commit install
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
+20 -9
View File
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div>
@@ -37,7 +37,7 @@
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## Основные возможности
@@ -45,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик.
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
7. 💻 Поддержка WebUI.
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
@@ -56,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб
<table align="center">
<tr align="center">
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
<th>✨ Проактивный Агент(Agent)</th>
<th>🚀 Универсальные Агентные возможности</th>
<th>🧩 Универсальные Агентные (Agentic) возможности</th>
<th>✨ Проактивный Агент (Agent)</th>
<th>🚀 Универсальные возможности Агента</th>
<th>🧩 1000+ плагинов сообщества</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,6 +83,16 @@ uv tool install astrbot
astrbot
```
#### Десктопное приложение (Tauri)
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
#### Установка в один клик через лаунчер (AstrBot Launcher)
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
#### Развёртывание BT-Panel
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
@@ -158,8 +168,9 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- WhatsApp (Скоро)
- LINE (Скоро)
**Поддерживаемые сообществом**
@@ -252,7 +263,7 @@ pre-commit install
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
+16 -5
View File
@@ -37,7 +37,7 @@
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## 主要功能
@@ -45,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
7. 💻 WebUI 支援。
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
@@ -58,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
<th>💙 角色扮演 & 情感陪伴</th>
<th>✨ 主動式 Agent</th>
<th>🚀 通用 Agentic 能力</th>
<th>🧩 900+ 社區外掛程式</th>
<th>🧩 1000+ 社區外掛程式</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,6 +83,16 @@ uv tool install astrbot
astrbot
```
#### 桌面應用部署(Tauri
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
#### 啟動器一鍵部署(AstrBot Launcher
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
#### 寶塔面板部署
AstrBot 與寶塔面板合作,已上架至寶塔面板。
@@ -158,8 +168,9 @@ paru -S astrbot-git
- Discord
- Satori
- Misskey
- LINE
- Whatsapp(即將支援)
- LINE(即將支援)
**社群維護**
@@ -252,7 +263,7 @@ pre-commit install
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
此外,本專案的誕生離不開以下開源專案的幫助:
+6
View File
@@ -24,6 +24,9 @@ from astrbot.core.star.register import (
register_on_llm_tool_respond as on_llm_tool_respond,
)
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request,
@@ -52,6 +55,9 @@ __all__ = [
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_plugin_error",
"on_plugin_loaded",
"on_plugin_unloaded",
"on_platform_loaded",
"on_waiting_llm_request",
"permission_type",
@@ -4,6 +4,7 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -62,6 +63,7 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
@@ -86,6 +88,8 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.update_conversation(
umo,
cid,
@@ -98,6 +102,30 @@ class ConversationCommands:
message.set_result(MessageEventResult().message(ret))
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话正在运行的 Agent"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
umo = message.unified_msg_origin
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
stopped_count = active_event_registry.stop_all(umo, exclude=message)
else:
stopped_count = active_event_registry.request_agent_stop_all(
umo,
exclude=message,
)
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"已请求停止 {stopped_count} 个运行中的任务。"
)
)
return
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin):
@@ -178,16 +206,33 @@ class ConversationCommands:
_titles[conv.cid] = title
"""遍历分页后的对话生成列表显示"""
provider_settings = cfg.get("provider_settings", {})
platform_name = message.get_platform_name()
for conv in conversations_paged:
persona_id = conv.persona_id
if not persona_id or persona_id == "[%None]":
persona = await self.context.persona_manager.get_default_persona_v3(
umo=message.unified_msg_origin,
)
persona_id = persona["name"]
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=message.unified_msg_origin,
conversation_persona_id=conv.persona_id,
platform_name=platform_name,
provider_settings=provider_settings,
)
if persona_id == "[%None]":
persona_name = ""
elif persona_id:
persona_name = persona_id
else:
persona_name = ""
if force_applied_persona_id:
persona_name = f"{persona_name} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
)
global_index += 1
@@ -221,6 +266,7 @@ class ConversationCommands:
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
@@ -229,6 +275,7 @@ class ConversationCommands:
message.set_result(MessageEventResult().message("已创建新对话。"))
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin,
@@ -321,7 +368,8 @@ class ConversationCommands:
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
@@ -334,18 +382,17 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
await self.context.conversation_manager.get_curr_conversation_id(umo)
)
if not session_curr_cid:
@@ -356,8 +403,10 @@ class ConversationCommands:
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
message.unified_msg_origin,
umo,
session_curr_cid,
)
@@ -1,7 +1,7 @@
import builtins
from typing import TYPE_CHECKING
from astrbot.api import sp, star
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING:
@@ -59,12 +59,7 @@ class PersonaCommands:
default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo,
)
force_applied_persona_id = (
await sp.get_async(
scope="umo", scope_id=umo, key="session_service_config", default={}
)
).get("persona_id")
force_applied_persona_id = None
curr_cid_title = ""
if cid:
@@ -80,10 +75,27 @@ class PersonaCommands:
),
)
return
if not conv.persona_id and conv.persona_id != "[%None]":
curr_persona_name = default_persona["name"]
else:
curr_persona_name = conv.persona_id
provider_settings = self.context.get_config(umo=umo).get(
"provider_settings",
{},
)
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=umo,
conversation_persona_id=conv.persona_id,
platform_name=message.get_platform_name(),
provider_settings=provider_settings,
)
if persona_id == "[%None]":
curr_persona_name = ""
elif persona_id:
curr_persona_name = persona_id
if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
@@ -132,6 +132,11 @@ class Main(star.Star):
"""重置 LLM 会话"""
await self.conversation_c.reset(message)
@filter.command("stop")
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话中正在运行的 Agent"""
await self.conversation_c.stop(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("model")
async def model_ls(
+2 -4
View File
@@ -70,7 +70,7 @@ class Main(star.Star):
header = HEADERS
header.update({"User-Agent": random.choice(USER_AGENTS)})
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url, headers=header, timeout=6) as response:
async with session.get(url, headers=header) as response:
html = await response.text(encoding="utf-8")
doc = Document(html)
ret = doc.summary(html_partial=True)
@@ -151,7 +151,6 @@ class Main(star.Star):
url,
json=payload,
headers=header,
timeout=6,
) as response:
if response.status != 200:
reason = await response.text()
@@ -183,7 +182,6 @@ class Main(star.Star):
url,
json=payload,
headers=header,
timeout=6,
) as response:
if response.status != 200:
reason = await response.text()
@@ -265,7 +263,7 @@ class Main(star.Star):
"transport": "sse",
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
"headers": {},
"timeout": 30,
"timeout": 600,
},
)
self.baidu_initialized = True
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.17.2"
__version__ = "4.18.3"
+53 -12
View File
@@ -4,19 +4,60 @@ from ..message import Message
class ContextTruncator:
"""Context truncator."""
def _has_tool_calls(self, message: Message) -> bool:
"""Check if a message contains tool calls."""
return (
message.role == "assistant"
and message.tool_calls is not None
and len(message.tool_calls) > 0
)
def fix_messages(self, messages: list[Message]) -> list[Message]:
fixed_messages = []
for message in messages:
if message.role == "tool":
# tool block 前面必须要有 user 和 assistant block
if len(fixed_messages) < 2:
# 这种情况可能是上下文被截断导致的
# 我们直接将之前的上下文都清空
fixed_messages = []
else:
fixed_messages.append(message)
else:
fixed_messages.append(message)
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
此方法确保:
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
"""
if not messages:
return messages
fixed_messages: list[Message] = []
pending_assistant: Message | None = None
pending_tools: list[Message] = []
def flush_pending_if_valid() -> None:
nonlocal pending_assistant, pending_tools
if pending_assistant is not None and pending_tools:
fixed_messages.append(pending_assistant)
fixed_messages.extend(pending_tools)
pending_assistant = None
pending_tools = []
for msg in messages:
if msg.role == "tool":
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
if pending_assistant is not None:
pending_tools.append(msg)
# else: 孤立的 tool 消息,直接忽略
continue
if self._has_tool_calls(msg):
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
flush_pending_if_valid()
pending_assistant = msg
continue
# 非 tool,且不含 tool_calls 的消息
# 先结束任何 pending 链,再正常追加
flush_pending_if_valid()
fixed_messages.append(msg)
# 结束时处理最后一个 pending 链
flush_pending_if_valid()
return fixed_messages
def truncate_by_turns(
+13
View File
@@ -44,6 +44,19 @@ class HandoffTool(FunctionTool, Generic[TContext]):
"type": "string",
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
},
"image_urls": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.",
},
"background_task": {
"type": "boolean",
"description": (
"Defaults to false. "
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
"Use false only for quick, immediate tasks."
),
},
},
}
@@ -137,6 +137,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.tool_executor = tool_executor
self.agent_hooks = agent_hooks
self.run_context = run_context
self._stop_requested = False
self._aborted = False
# These two are used for tool schema mode handling
# We now have two modes:
@@ -328,6 +330,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
),
)
if self._stop_requested:
llm_resp_result = LLMResponse(
role="assistant",
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
reasoning_content=llm_response.reasoning_content,
reasoning_signature=llm_response.reasoning_signature,
)
break
continue
llm_resp_result = llm_response
@@ -339,6 +349,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
break # got final response
if not llm_resp_result:
if self._stop_requested:
llm_resp_result = LLMResponse(role="assistant", completion_text="")
else:
return
if self._stop_requested:
logger.info("Agent execution was requested to stop by user.")
llm_resp = llm_resp_result
if llm_resp.role != "assistant":
llm_resp = LLMResponse(
role="assistant",
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
)
self.final_llm_resp = llm_resp
self._aborted = True
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if parts:
self.run_context.messages.append(
Message(role="assistant", content=parts)
)
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
yield AgentResponse(
type="aborted",
data=AgentResponseData(chain=MessageChain(type="aborted")),
)
return
# 处理 LLM 响应
@@ -357,6 +409,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
),
)
return
if not llm_resp.tools_call_name:
# 如果没有工具调用,转换到完成状态
@@ -847,5 +900,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
def request_stop(self) -> None:
self._stop_requested = True
def was_aborted(self) -> bool:
return self._aborted
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp
+3
View File
@@ -285,6 +285,9 @@ class ToolSet:
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
# see #5217
if "additionalProperties" in prop_value:
del prop_value["additionalProperties"]
properties[key] = prop_value
if properties:
+135 -17
View File
@@ -20,15 +20,81 @@ from astrbot.core.provider.provider import TTSProvider
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
def _should_stop_agent(astr_event) -> bool:
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
def _truncate_tool_result(text: str, limit: int = 70) -> str:
if limit <= 0:
return ""
if len(text) <= limit:
return text
if limit <= 3:
return text[:limit]
return f"{text[: limit - 3]}..."
def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
if not msg_chain.chain:
return None
first_comp = msg_chain.chain[0]
if isinstance(first_comp, Json) and isinstance(first_comp.data, dict):
return first_comp.data
return None
def _record_tool_call_name(
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
) -> None:
if not isinstance(tool_info, dict):
return
tool_call_id = tool_info.get("id")
tool_name = tool_info.get("name")
if tool_call_id is None or tool_name is None:
return
tool_name_by_call_id[str(tool_call_id)] = str(tool_name)
def _build_tool_call_status_message(tool_info: dict | None) -> str:
if tool_info:
return f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
return "🔨 调用工具..."
def _build_tool_result_status_message(
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
) -> str:
tool_name = "unknown"
tool_result = ""
result_data = _extract_chain_json_data(msg_chain)
if result_data:
tool_call_id = result_data.get("id")
if tool_call_id is not None:
tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown")
tool_result = str(result_data.get("result", ""))
if not tool_result:
tool_result = msg_chain.get_plain_text(with_other_comps_mark=True)
tool_result = _truncate_tool_result(tool_result, 70)
status_msg = f"🔨 调用工具: {tool_name}"
if tool_result:
status_msg = f"{status_msg}\n📎 返回结果: {tool_result}"
return status_msg
async def run_agent(
agent_runner: AgentRunner,
max_step: int = 30,
show_tool_use: bool = True,
show_tool_call_result: bool = False,
stream_to_general: bool = False,
show_reasoning: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
tool_name_by_call_id: dict[str, str] = {}
while step_idx < max_step + 1:
step_idx += 1
@@ -48,10 +114,28 @@ async def run_agent(
)
)
stop_watcher = asyncio.create_task(
_watch_agent_stop_signal(agent_runner, astr_event),
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
if _should_stop_agent(astr_event):
agent_runner.request_stop()
if resp.type == "aborted":
if not stop_watcher.done():
stop_watcher.cancel()
try:
await stop_watcher
except asyncio.CancelledError:
pass
astr_event.set_extra("agent_user_aborted", True)
astr_event.set_extra("agent_stop_requested", False)
return
if _should_stop_agent(astr_event):
continue
if resp.type == "tool_call_result":
msg_chain = resp.data["chain"]
@@ -68,6 +152,13 @@ async def run_agent(
continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
elif show_tool_use and show_tool_call_result:
status_msg = _build_tool_result_status_message(
msg_chain, tool_name_by_call_id
)
await astr_event.send(
MessageChain(type="tool_call").message(status_msg)
)
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
@@ -75,25 +166,22 @@ async def run_agent(
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
tool_info = None
if resp.data["chain"].chain:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
tool_info = json_comp.data
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
)
tool_info = _extract_chain_json_data(resp.data["chain"])
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
)
_record_tool_call_name(tool_info, tool_name_by_call_id)
if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"])
elif show_tool_use:
if tool_info:
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
if show_tool_call_result and isinstance(tool_info, dict):
# Delay tool status notification until tool_call_result.
continue
chain = MessageChain(type="tool_call").message(
_build_tool_call_status_message(tool_info)
)
await astr_event.send(chain)
continue
@@ -120,6 +208,12 @@ async def run_agent(
# display the reasoning content only when configured
continue
yield resp.data["chain"] # MessageChain
if not stop_watcher.done():
stop_watcher.cancel()
try:
await stop_watcher
except asyncio.CancelledError:
pass
if agent_runner.done():
# send agent stats to webchat
if astr_event.get_platform_name() == "webchat":
@@ -133,6 +227,12 @@ async def run_agent(
break
except Exception as e:
if "stop_watcher" in locals() and not stop_watcher.done():
stop_watcher.cancel()
try:
await stop_watcher
except asyncio.CancelledError:
pass
logger.error(traceback.format_exc())
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
@@ -155,11 +255,20 @@ async def run_agent(
return
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
while not agent_runner.done():
if _should_stop_agent(astr_event):
agent_runner.request_stop()
return
await asyncio.sleep(0.5)
async def run_live_agent(
agent_runner: AgentRunner,
tts_provider: TTSProvider | None = None,
max_step: int = 30,
show_tool_use: bool = True,
show_tool_call_result: bool = False,
show_reasoning: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""Live Mode 的 Agent 运行器,支持流式 TTS
@@ -169,6 +278,7 @@ async def run_live_agent(
tts_provider: TTS Provider 实例
max_step: 最大步数
show_tool_use: 是否显示工具使用
show_tool_call_result: 是否显示工具返回结果
show_reasoning: 是否显示推理过程
Yields:
@@ -180,6 +290,7 @@ async def run_live_agent(
agent_runner,
max_step=max_step,
show_tool_use=show_tool_use,
show_tool_call_result=show_tool_call_result,
stream_to_general=False,
show_reasoning=show_reasoning,
):
@@ -208,7 +319,12 @@ async def run_live_agent(
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
feeder_task = asyncio.create_task(
_run_agent_feeder(
agent_runner, text_queue, max_step, show_tool_use, show_reasoning
agent_runner,
text_queue,
max_step,
show_tool_use,
show_tool_call_result,
show_reasoning,
)
)
@@ -294,6 +410,7 @@ async def _run_agent_feeder(
text_queue: asyncio.Queue,
max_step: int,
show_tool_use: bool,
show_tool_call_result: bool,
show_reasoning: bool,
) -> None:
"""运行 Agent 并将文本输出分句放入队列"""
@@ -303,6 +420,7 @@ async def _run_agent_feeder(
agent_runner,
max_step=max_step,
show_tool_use=show_tool_use,
show_tool_call_result=show_tool_call_result,
stream_to_general=False,
show_reasoning=show_reasoning,
):
+213 -35
View File
@@ -17,6 +17,12 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
PYTHON_TOOL,
SEND_MESSAGE_TO_USER_TOOL,
)
from astrbot.core.cron.events import CronMessageEvent
@@ -45,6 +51,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
"""
if isinstance(tool, HandoffTool):
is_bg = tool_args.pop("background_task", False)
if is_bg:
async for r in cls._execute_handoff_background(
tool, run_context, **tool_args
):
yield r
return
async for r in cls._execute_handoff(tool, run_context, **tool_args):
yield r
return
@@ -84,6 +97,65 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
yield r
return
@classmethod
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
if runtime == "sandbox":
return {
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
PYTHON_TOOL.name: PYTHON_TOOL,
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
}
if runtime == "local":
return {
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
}
return {}
@classmethod
def _build_handoff_toolset(
cls,
run_context: ContextWrapper[AstrAgentContext],
tools: list[str | FunctionTool] | None,
) -> ToolSet | None:
ctx = run_context.context.context
event = run_context.context.event
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.
if tools is None:
toolset = ToolSet()
for registered_tool in llm_tools.func_list:
if isinstance(registered_tool, HandoffTool):
continue
if registered_tool.active:
toolset.add_tool(registered_tool)
for runtime_tool in runtime_computer_tools.values():
toolset.add_tool(runtime_tool)
return None if toolset.empty() else toolset
if not tools:
return None
toolset = ToolSet()
for tool_name_or_obj in tools:
if isinstance(tool_name_or_obj, str):
registered_tool = llm_tools.get_func(tool_name_or_obj)
if registered_tool and registered_tool.active:
toolset.add_tool(registered_tool)
continue
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
if runtime_tool:
toolset.add_tool(runtime_tool)
elif isinstance(tool_name_or_obj, FunctionTool):
toolset.add_tool(tool_name_or_obj)
return None if toolset.empty() else toolset
@classmethod
async def _execute_handoff(
cls,
@@ -92,20 +164,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
**tool_args,
):
input_ = tool_args.get("input")
image_urls = tool_args.get("image_urls")
# make toolset for the agent
tools = tool.agent.tools
if tools:
toolset = ToolSet()
for t in tools:
if isinstance(t, str):
_t = llm_tools.get_func(t)
if _t:
toolset.add_tool(_t)
elif isinstance(t, FunctionTool):
toolset.add_tool(t)
else:
toolset = None
# Build handoff toolset from registered tools plus runtime computer tools.
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
ctx = run_context.context.context
event = run_context.context.event
@@ -136,16 +198,98 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event=event,
chat_provider_id=prov_id,
prompt=input_,
image_urls=image_urls,
system_prompt=tool.agent.instructions,
tools=toolset,
contexts=contexts,
max_steps=30,
run_hooks=tool.agent.run_hooks,
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
)
yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
)
@classmethod
async def _execute_handoff_background(
cls,
tool: HandoffTool,
run_context: ContextWrapper[AstrAgentContext],
**tool_args,
):
"""Execute a handoff as a background task.
Immediately yields a success response with a task_id, then runs
the subagent asynchronously. When the subagent finishes, a
``CronMessageEvent`` is created so the main LLM can inform the
user of the result the same pattern used by
``_execute_background`` for regular background tasks.
"""
task_id = uuid.uuid4().hex
async def _run_handoff_in_background() -> None:
try:
await cls._do_handoff_background(
tool=tool,
run_context=run_context,
task_id=task_id,
**tool_args,
)
except Exception as e: # noqa: BLE001
logger.error(
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
exc_info=True,
)
asyncio.create_task(_run_handoff_in_background())
text_content = mcp.types.TextContent(
type="text",
text=(
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
f"You will be notified when it finishes."
),
)
yield mcp.types.CallToolResult(content=[text_content])
@classmethod
async def _do_handoff_background(
cls,
tool: HandoffTool,
run_context: ContextWrapper[AstrAgentContext],
task_id: str,
**tool_args,
) -> None:
"""Run the subagent handoff and, on completion, wake the main agent."""
result_text = ""
try:
async for r in cls._execute_handoff(tool, run_context, **tool_args):
if isinstance(r, mcp.types.CallToolResult):
for content in r.content:
if isinstance(content, mcp.types.TextContent):
result_text += content.text + "\n"
except Exception as e:
result_text = (
f"error: Background task execution failed, internal error: {e!s}"
)
event = run_context.context.event
await cls._wake_main_agent_for_background_result(
run_context=run_context,
task_id=task_id,
tool_name=tool.name,
result_text=result_text,
tool_args=tool_args,
note=(
event.get_extra("background_note")
or f"Background task for subagent '{tool.agent.name}' finished."
),
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
extra_result_fields={"subagent_name": tool.agent.name},
)
@classmethod
async def _execute_background(
cls,
@@ -154,12 +298,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
task_id: str,
**tool_args,
) -> None:
from astrbot.core.astr_main_agent import (
MainAgentBuildConfig,
_get_session_conv,
build_main_agent,
)
# run the tool
result_text = ""
try:
@@ -177,21 +315,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
f"error: Background task execution failed, internal error: {e!s}"
)
event = run_context.context.event
await cls._wake_main_agent_for_background_result(
run_context=run_context,
task_id=task_id,
tool_name=tool.name,
result_text=result_text,
tool_args=tool_args,
note=(
event.get_extra("background_note")
or f"Background task {tool.name} finished."
),
summary_name=tool.name,
)
@classmethod
async def _wake_main_agent_for_background_result(
cls,
run_context: ContextWrapper[AstrAgentContext],
*,
task_id: str,
tool_name: str,
result_text: str,
tool_args: dict[str, T.Any],
note: str,
summary_name: str,
extra_result_fields: dict[str, T.Any] | None = None,
) -> None:
from astrbot.core.astr_main_agent import (
MainAgentBuildConfig,
_get_session_conv,
build_main_agent,
)
event = run_context.context.event
ctx = run_context.context.context
note = (
event.get_extra("background_note")
or f"Background task {tool.name} finished."
)
extras = {
"background_task_result": {
"task_id": task_id,
"tool_name": tool.name,
"result": result_text or "",
"tool_args": tool_args,
}
task_result = {
"task_id": task_id,
"tool_name": tool_name,
"result": result_text or "",
"tool_args": tool_args,
}
if extra_result_fields:
task_result.update(extra_result_fields)
extras = {"background_task_result": task_result}
session = MessageSession.from_str(event.unified_msg_origin)
cron_event = CronMessageEvent(
context=ctx,
@@ -201,7 +371,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
message_type=session.message_type,
)
cron_event.role = event.role
config = MainAgentBuildConfig(tool_call_timeout=3600)
config = MainAgentBuildConfig(
tool_call_timeout=3600,
streaming_response=ctx.get_config()
.get("provider_settings", {})
.get("stream", False),
)
req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
@@ -222,8 +397,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
)
req.prompt = (
"Proceed according to your system instructions. "
"Output using same language as previous conversation."
" After completing your task, summarize and output your actions and results."
"Output using same language as previous conversation. "
"If you need to deliver the result to the user immediately, "
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
"otherwise the user will not see the result. "
"After completing your task, summarize and output your actions and results. "
)
if not req.func_tool:
req.func_tool = ToolSet()
@@ -233,7 +411,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event=cron_event, plugin_context=ctx, config=config, req=req
)
if not result:
logger.error("Failed to build main agent for background task job.")
logger.error(f"Failed to build main agent for background task {tool_name}.")
return
runner = result.agent_runner
@@ -243,7 +421,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
llm_resp = runner.get_final_llm_resp()
task_meta = extras.get("background_task_result", {})
summary_note = (
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
f"[BackgroundTask] {summary_name} "
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
f"Result: {task_meta.get('result') or result_text or 'no content'}"
)
+13 -45
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import builtins
import copy
import datetime
import json
@@ -10,7 +9,6 @@ import zoneinfo
from collections.abc import Coroutine
from dataclasses import dataclass, field
from astrbot.api import sp
from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
@@ -45,7 +43,6 @@ from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import star_map
from astrbot.core.provider.manager import llm_tools
from astrbot.core.tools.cron_tools import (
CREATE_CRON_JOB_TOOL,
DELETE_CRON_JOB_TOOL,
@@ -276,47 +273,26 @@ async def _ensure_persona_and_skills(
if not req.conversation:
return
# get persona ID
# 1. from session service config - highest priority
persona_id = (
await sp.get_async(
scope="umo",
scope_id=event.unified_msg_origin,
key="session_service_config",
default={},
)
).get("persona_id")
if not persona_id:
# 2. from conversation setting - second priority
persona_id = req.conversation.persona_id
if persona_id == "[%None]":
# explicitly set to no persona
pass
elif persona_id is None:
# 3. from config default persona setting - last priority
persona_id = cfg.get("default_personality")
persona = next(
builtins.filter(
lambda persona: persona["name"] == persona_id,
plugin_context.persona_manager.personas_v3,
),
None,
(
persona_id,
persona,
_,
use_webchat_special_default,
) = await plugin_context.persona_manager.resolve_selected_persona(
umo=event.unified_msg_origin,
conversation_persona_id=req.conversation.persona_id,
platform_name=event.get_platform_name(),
provider_settings=cfg,
)
if persona:
# Inject persona system prompt
if prompt := persona["prompt"]:
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
req.contexts[:0] = begin_dialogs
else:
# special handling for webchat persona
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
persona_id = "_chatui_default_"
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
elif use_webchat_special_default:
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# Inject skills prompt
runtime = cfg.get("computer_use_runtime", "local")
@@ -770,14 +746,6 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
req.func_tool = new_tool_set
else:
# mcp tools
tool_set = req.func_tool
if not tool_set:
tool_set = ToolSet()
for tool in llm_tools.func_list:
if isinstance(tool, MCPTool):
tool_set.add_tool(tool)
async def _handle_webchat(
+5
View File
@@ -11,6 +11,7 @@ from astrbot.core.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..computer_client import get_booter
from .permissions import check_admin_permission
# @dataclass
# class CreateFileTool(FunctionTool):
@@ -102,6 +103,8 @@ class FileUploadTool(FunctionTool):
context: ContextWrapper[AstrAgentContext],
local_path: str,
) -> str | None:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
@@ -161,6 +164,8 @@ class FileDownloadTool(FunctionTool):
remote_path: str,
also_send_to_user: bool = True,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
@@ -0,0 +1,19 @@
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
def check_admin_permission(
context: ContextWrapper[AstrAgentContext], operation_name: str
) -> str | None:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
if require_admin and context.context.event.role != "admin":
return (
f"error: Permission denied. {operation_name} is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. "
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
return None
+13 -7
View File
@@ -5,8 +5,10 @@ import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.computer.tools.permissions import check_admin_permission
from astrbot.core.message.message_event_result import MessageChain
param_schema = {
"type": "object",
@@ -25,7 +27,7 @@ param_schema = {
}
def handle_result(result: dict) -> ToolExecResult:
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
@@ -44,6 +46,9 @@ def handle_result(result: dict) -> ToolExecResult:
type="image", data=img["image/png"], mimeType="image/png"
)
)
if event.get_platform_name() == "webchat":
await event.send(message=MessageChain().base64_image(img["image/png"]))
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
@@ -62,13 +67,15 @@ class PythonTool(FunctionTool):
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Python execution"):
return permission_error
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -83,12 +90,11 @@ class LocalPythonTool(FunctionTool):
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
if permission_error := check_admin_permission(context, "Python execution"):
return permission_error
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
+3 -2
View File
@@ -7,6 +7,7 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter, get_local_booter
from .permissions import check_admin_permission
@dataclass
@@ -46,8 +47,8 @@ class ExecuteShellTool(FunctionTool):
background: bool = False,
env: dict = {},
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
if permission_error := check_admin_permission(context, "Shell execution"):
return permission_error
if self.is_local:
sb = get_local_booter()
+3
View File
@@ -52,6 +52,9 @@ class AstrBotConfig(dict):
with open(config_path, encoding="utf-8-sig") as f:
conf_str = f.read()
# Handle UTF-8 BOM if present
if conf_str.startswith("\ufeff"):
conf_str = conf_str[1:]
conf = json.loads(conf_str)
# 检查配置完整性,并插入
+85 -8
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.17.2"
VERSION = "4.18.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -100,6 +100,7 @@ DEFAULT_CONFIG = {
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"show_tool_call_result": False,
"sanitize_context_by_modalities": False,
"max_quoted_fallback_images": 20,
"quoted_message_parser": {
@@ -128,6 +129,7 @@ DEFAULT_CONFIG = {
"add_cron_tools": True,
},
"computer_use_runtime": "local",
"computer_use_require_admin": True,
"sandbox": {
"booter": "shipyard",
"shipyard_endpoint": "",
@@ -423,7 +425,15 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback",
},
# LINE's config is located in line_adapter.py
"Line": {
"id": "line",
"type": "line",
"enable": False,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
},
"Satori": {
"id": "satori",
"type": "satori",
@@ -978,7 +988,7 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"proxy": "",
"anth_thinking_config": {"budget": 0},
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
},
"Moonshot": {
"id": "moonshot",
@@ -1029,6 +1039,42 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"AIHubMix": {
"id": "aihubmix",
"provider": "aihubmix",
"type": "aihubmix_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://aihubmix.com/v1",
"proxy": "",
"custom_headers": {},
},
"OpenRouter": {
"id": "openrouter",
"provider": "openrouter",
"type": "openrouter_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://openrouter.ai/v1",
"proxy": "",
"custom_headers": {},
},
"NVIDIA": {
"id": "nvidia",
"provider": "nvidia",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://integrate.api.nvidia.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
@@ -1425,6 +1471,7 @@ CONFIG_METADATA_2 = {
"type": "openai_embedding",
"provider": "openai",
"provider_type": "embedding",
"hint": "provider_group.provider.openai_embedding.hint",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "",
@@ -1438,6 +1485,7 @@ CONFIG_METADATA_2 = {
"type": "gemini_embedding",
"provider": "google",
"provider_type": "embedding",
"hint": "provider_group.provider.gemini_embedding.hint",
"enable": True,
"embedding_api_key": "",
"embedding_api_base": "",
@@ -1927,13 +1975,25 @@ CONFIG_METADATA_2 = {
},
},
"anth_thinking_config": {
"description": "Thinking Config",
"description": "思考配置",
"type": "object",
"items": {
"type": {
"description": "思考类型",
"type": "string",
"options": ["", "adaptive"],
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
},
"budget": {
"description": "Thinking Budget",
"description": "思考预算",
"type": "int",
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
},
"effort": {
"description": "思考深度",
"type": "string",
"options": ["", "low", "medium", "high", "max"],
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high''max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
},
},
},
@@ -2142,9 +2202,9 @@ CONFIG_METADATA_2 = {
"type": "string",
},
"proxy": {
"description": "代理地址",
"description": "provider_group.provider.proxy.description",
"type": "string",
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
"hint": "provider_group.provider.proxy.hint",
},
"model": {
"description": "模型 ID",
@@ -2257,6 +2317,9 @@ CONFIG_METADATA_2 = {
"show_tool_use_status": {
"type": "bool",
},
"show_tool_call_result": {
"type": "bool",
},
"unsupported_streaming_strategy": {
"type": "string",
},
@@ -2713,6 +2776,11 @@ CONFIG_METADATA_3 = {
"labels": ["", "本地", "沙箱"],
"hint": "选择 Computer Use 运行环境。",
},
"provider_settings.computer_use_require_admin": {
"description": "需要 AstrBot 管理员权限",
"type": "bool",
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
@@ -2940,6 +3008,15 @@ CONFIG_METADATA_3 = {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.show_tool_call_result": {
"description": "输出函数调用返回结果",
"type": "bool",
"hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。",
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.show_tool_use_status": True,
},
},
"provider_settings.sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"type": "bool",
+66
View File
@@ -8,6 +8,7 @@ from deprecated import deprecated
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from astrbot.core.db.po import (
ApiKey,
Attachment,
ChatUIProject,
CommandConfig,
@@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC):
"""
...
@abc.abstractmethod
async def create_api_key(
self,
name: str,
key_hash: str,
key_prefix: str,
scopes: list[str] | None,
created_by: str,
expires_at: datetime.datetime | None = None,
) -> ApiKey:
"""Create a new API key record."""
...
@abc.abstractmethod
async def list_api_keys(self) -> list[ApiKey]:
"""List all API keys."""
...
@abc.abstractmethod
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
"""Get an API key by key_id."""
...
@abc.abstractmethod
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
"""Get an active API key by hash (not revoked, not expired)."""
...
@abc.abstractmethod
async def touch_api_key(self, key_id: str) -> None:
"""Update last_used_at of an API key."""
...
@abc.abstractmethod
async def revoke_api_key(self, key_id: str) -> bool:
"""Revoke an API key.
Returns True when the key exists and is updated.
"""
...
@abc.abstractmethod
async def delete_api_key(self, key_id: str) -> bool:
"""Delete an API key.
Returns True when the key exists and is deleted.
"""
...
@abc.abstractmethod
async def insert_persona(
self,
@@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC):
"""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator_paginated(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
exclude_project_sessions: bool = False,
) -> tuple[list[dict], int]:
"""Get paginated platform sessions and total count for a creator.
Returns:
tuple[list[dict], int]: (sessions_with_project_info, total_count)
"""
...
@abc.abstractmethod
async def update_platform_session(
self,
+37
View File
@@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True):
)
class ApiKey(TimestampMixin, SQLModel, table=True):
"""API keys used by external developers to access Open APIs."""
__tablename__: str = "api_keys"
inner_id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
key_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
name: str = Field(max_length=255, nullable=False)
key_hash: str = Field(max_length=128, nullable=False, unique=True)
key_prefix: str = Field(max_length=24, nullable=False)
scopes: list | None = Field(default=None, sa_type=JSON)
created_by: str = Field(max_length=255, nullable=False)
last_used_at: datetime | None = Field(default=None)
expires_at: datetime | None = Field(default=None)
revoked_at: datetime | None = Field(default=None)
__table_args__ = (
UniqueConstraint(
"key_id",
name="uix_api_key_id",
),
UniqueConstraint(
"key_hash",
name="uix_api_key_hash",
),
)
class ChatUIProject(TimestampMixin, SQLModel, table=True):
"""This class represents projects for organizing ChatUI conversations.
+181 -42
View File
@@ -4,12 +4,13 @@ import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult
from sqlalchemy import CursorResult, Row
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import (
ApiKey,
Attachment,
ChatUIProject,
CommandConfig,
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
result = T.cast(CursorResult, await session.execute(query))
return result.rowcount
async def create_api_key(
self,
name: str,
key_hash: str,
key_prefix: str,
scopes: list[str] | None,
created_by: str,
expires_at: datetime | None = None,
) -> ApiKey:
"""Create a new API key record."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
api_key = ApiKey(
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
scopes=scopes,
created_by=created_by,
expires_at=expires_at,
)
session.add(api_key)
await session.flush()
await session.refresh(api_key)
return api_key
async def list_api_keys(self) -> list[ApiKey]:
"""List all API keys."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ApiKey).order_by(desc(ApiKey.created_at))
)
return list(result.scalars().all())
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
"""Get an API key by key_id."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ApiKey).where(ApiKey.key_id == key_id)
)
return result.scalar_one_or_none()
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
"""Get an active API key by hash (not revoked, not expired)."""
async with self.get_db() as session:
session: AsyncSession
now = datetime.now(timezone.utc)
query = select(ApiKey).where(
ApiKey.key_hash == key_hash,
col(ApiKey.revoked_at).is_(None),
or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now),
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def touch_api_key(self, key_id: str) -> None:
"""Update last_used_at of an API key."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
update(ApiKey)
.where(col(ApiKey.key_id) == key_id)
.values(last_used_at=datetime.now(timezone.utc)),
)
async def revoke_api_key(self, key_id: str) -> bool:
"""Revoke an API key."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = (
update(ApiKey)
.where(col(ApiKey.key_id) == key_id)
.values(revoked_at=datetime.now(timezone.utc))
)
result = T.cast(CursorResult, await session.execute(query))
return result.rowcount > 0
async def delete_api_key(self, key_id: str) -> bool:
"""Delete an API key."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
result = T.cast(
CursorResult,
await session.execute(
delete(ApiKey).where(col(ApiKey.key_id) == key_id)
),
)
return result.rowcount > 0
async def insert_persona(
self,
persona_id,
@@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase):
Returns a list of dicts containing session info and project info (if session belongs to a project).
"""
(
sessions_with_projects,
_,
) = await self.get_platform_sessions_by_creator_paginated(
creator=creator,
platform_id=platform_id,
page=page,
page_size=page_size,
exclude_project_sessions=False,
)
return sessions_with_projects
@staticmethod
def _build_platform_sessions_query(
creator: str,
platform_id: str | None = None,
exclude_project_sessions: bool = False,
):
query = (
select(
PlatformSession,
col(ChatUIProject.project_id),
col(ChatUIProject.title).label("project_title"),
col(ChatUIProject.emoji).label("project_emoji"),
)
.outerjoin(
SessionProjectRelation,
col(PlatformSession.session_id)
== col(SessionProjectRelation.session_id),
)
.outerjoin(
ChatUIProject,
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
)
.where(col(PlatformSession.creator) == creator)
)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
if exclude_project_sessions:
query = query.where(col(ChatUIProject.project_id).is_(None))
return query
@staticmethod
def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
sessions_with_projects = []
for row in rows:
platform_session = row[0]
project_id = row[1]
project_title = row[2]
project_emoji = row[3]
session_dict = {
"session": platform_session,
"project_id": project_id,
"project_title": project_title,
"project_emoji": project_emoji,
}
sessions_with_projects.append(session_dict)
return sessions_with_projects
async def get_platform_sessions_by_creator_paginated(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
exclude_project_sessions: bool = False,
) -> tuple[list[dict], int]:
"""Get paginated Platform sessions for a creator with total count."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
query = (
select(
PlatformSession,
col(ChatUIProject.project_id),
col(ChatUIProject.title).label("project_title"),
col(ChatUIProject.emoji).label("project_emoji"),
)
.outerjoin(
SessionProjectRelation,
col(PlatformSession.session_id)
== col(SessionProjectRelation.session_id),
)
.outerjoin(
ChatUIProject,
col(SessionProjectRelation.project_id)
== col(ChatUIProject.project_id),
)
.where(col(PlatformSession.creator) == creator)
base_query = self._build_platform_sessions_query(
creator=creator,
platform_id=platform_id,
exclude_project_sessions=exclude_project_sessions,
)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
total_result = await session.execute(
select(func.count()).select_from(base_query.subquery())
)
total = int(total_result.scalar_one() or 0)
query = (
query.order_by(desc(PlatformSession.updated_at))
result_query = (
base_query.order_by(desc(PlatformSession.updated_at))
.offset(offset)
.limit(page_size)
)
result = await session.execute(query)
result = await session.execute(result_query)
# Convert to list of dicts with session and project info
sessions_with_projects = []
for row in result.all():
platform_session = row[0]
project_id = row[1]
project_title = row[2]
project_emoji = row[3]
session_dict = {
"session": platform_session,
"project_id": project_id,
"project_title": project_title,
"project_emoji": project_emoji,
}
sessions_with_projects.append(session_dict)
return sessions_with_projects
sessions_with_projects = self._rows_to_session_dicts(result.all())
return sessions_with_projects, total
async def update_platform_session(
self,
+45 -2
View File
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
KBMedia,
KnowledgeBase,
)
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
class KBSQLiteDatabase:
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
def __init__(self, db_path: str | None = None) -> None:
"""初始化知识库数据库
Args:
db_path: 数据库文件路径, 默认 data/knowledge_base/kb.db
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
"""
if db_path is None:
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
@@ -253,6 +256,46 @@ class KBSQLiteDatabase:
"knowledge_base": row[1],
}
async def get_documents_with_metadata_batch(
self, doc_ids: set[str]
) -> dict[str, dict]:
"""批量获取文档及其所属知识库元数据
Args:
doc_ids: 文档 ID 集合
Returns:
dict: doc_id -> {"document": KBDocument, "knowledge_base": KnowledgeBase}
"""
if not doc_ids:
return {}
metadata_map: dict[str, dict] = {}
# SQLite 参数上限为 999,分片查询避免超限
chunk_size = 900
doc_id_list = list(doc_ids)
async with self.get_db() as session:
for i in range(0, len(doc_id_list), chunk_size):
chunk = doc_id_list[i : i + chunk_size]
stmt = (
select(KBDocument, KnowledgeBase)
.join(
KnowledgeBase,
col(KBDocument.kb_id) == col(KnowledgeBase.kb_id),
)
.where(col(KBDocument.doc_id).in_(chunk))
)
result = await session.execute(stmt)
for row in result.all():
metadata_map[row[0].doc_id] = {
"document": row[0],
"knowledge_base": row[1],
}
return metadata_map
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
"""删除单个文档及其相关数据"""
# 在知识库表中删除
+3 -2
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from astrbot.core import logger
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
# from .chunking.fixed_size import FixedSizeChunker
from .chunking.recursive import RecursiveCharacterChunker
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
from .retrieval.rank_fusion import RankFusion
from .retrieval.sparse_retriever import SparseRetriever
FILES_PATH = "data/knowledge_base"
FILES_PATH = get_astrbot_knowledge_base_path()
DB_PATH = Path(FILES_PATH) / "kb.db"
"""Knowledge Base storage root directory"""
CHUNKER = RecursiveCharacterChunker()
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
self,
provider_manager: ProviderManager,
) -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
self.provider_manager = provider_manager
self._session_deleted_callback_registered = False
@@ -142,10 +142,13 @@ class RetrievalManager:
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.",
)
# 4. 转换为 RetrievalResult (获取元数据)
# 4. 转换为 RetrievalResult (批量获取元数据)
doc_ids = {fr.doc_id for fr in fused_results}
metadata_map = await self.kb_db.get_documents_with_metadata_batch(doc_ids)
retrieval_results = []
for fr in fused_results:
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
metadata_dict = metadata_map.get(fr.doc_id)
if metadata_dict:
retrieval_results.append(
RetrievalResult(
+56 -25
View File
@@ -25,10 +25,14 @@ import asyncio
import base64
import json
import os
import sys
import uuid
from enum import Enum
from pydantic.v1 import BaseModel
if sys.version_info >= (3, 14):
from pydantic import BaseModel
else:
from pydantic.v1 import BaseModel
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type = ComponentType.Plain
type: ComponentType = ComponentType.Plain
text: str
convert: bool | None = True
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
class Face(BaseMessageComponent):
type = ComponentType.Face
type: ComponentType = ComponentType.Face
id: int
def __init__(self, **_) -> None:
@@ -108,13 +112,15 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type = ComponentType.Record
type: ComponentType = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
cache: bool | None = True
proxy: bool | None = True
timeout: int | None = 0
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
text: str | None = None
# 额外
path: str | None
@@ -215,7 +221,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type = ComponentType.Video
type: ComponentType = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
@@ -301,7 +307,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type = ComponentType.At
type: ComponentType = ComponentType.At
qq: int | str # 此处str为all时代表所有人
name: str | None = ""
@@ -323,28 +329,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type = ComponentType.RPS
type: ComponentType = ComponentType.RPS
def __init__(self, **_) -> None:
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type = ComponentType.Dice
type: ComponentType = ComponentType.Dice
def __init__(self, **_) -> None:
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type = ComponentType.Shake
type: ComponentType = ComponentType.Shake
def __init__(self, **_) -> None:
super().__init__(**_)
class Share(BaseMessageComponent):
type = ComponentType.Share
type: ComponentType = ComponentType.Share
url: str
title: str
content: str | None = ""
@@ -355,7 +361,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type = ComponentType.Contact
type: ComponentType = ComponentType.Contact
_type: str # type 字段冲突
id: int | None = 0
@@ -364,7 +370,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type = ComponentType.Location
type: ComponentType = ComponentType.Location
lat: float
lon: float
title: str | None = ""
@@ -375,7 +381,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type = ComponentType.Music
type: ComponentType = ComponentType.Music
_type: str
id: int | None = 0
url: str | None = ""
@@ -392,7 +398,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type = ComponentType.Image
type: ComponentType = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
@@ -507,7 +513,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type = ComponentType.Reply
type: ComponentType = ComponentType.Reply
id: str | int
"""所引用的消息 ID"""
chain: list["BaseMessageComponent"] | None = []
@@ -543,7 +549,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type = ComponentType.Forward
type: ComponentType = ComponentType.Forward
id: str
def __init__(self, **_) -> None:
@@ -553,7 +559,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type = ComponentType.Node
type: ComponentType = ComponentType.Node
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
@@ -605,7 +611,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type = ComponentType.Nodes
type: ComponentType = ComponentType.Nodes
nodes: list[Node]
def __init__(self, nodes: list[Node], **_) -> None:
@@ -631,7 +637,7 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
type: ComponentType = ComponentType.Json
data: dict
def __init__(self, data: str | dict, **_) -> None:
@@ -641,14 +647,14 @@ class Json(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type = ComponentType.Unknown
type: ComponentType = ComponentType.Unknown
text: str
class File(BaseMessageComponent):
"""文件消息段"""
type = ComponentType.File
type: ComponentType = ComponentType.File
name: str | None = "" # 名字
file_: str | None = "" # 本地路径
url: str | None = "" # url
@@ -714,13 +720,38 @@ class File(BaseMessageComponent):
if allow_return_url and self.url:
return self.url
if self.file_ and os.path.exists(self.file_):
return os.path.abspath(self.file_)
if self.file_:
path = self.file_
if path.startswith("file://"):
# 处理 file:// (2 slashes) 或 file:/// (3 slashes)
# pathlib.as_uri() 通常生成 file:///
path = path[7:]
# 兼容 Windows: file:///C:/path -> /C:/path -> C:/path
if (
os.name == "nt"
and len(path) > 2
and path[0] == "/"
and path[2] == ":"
):
path = path[1:]
if os.path.exists(path):
return os.path.abspath(path)
if self.url:
await self._download_file()
if self.file_:
return os.path.abspath(self.file_)
path = self.file_
if path.startswith("file://"):
path = path[7:]
if (
os.name == "nt"
and len(path) > 2
and path[0] == "/"
and path[2] == ":"
):
path = path[1:]
return os.path.abspath(path)
return ""
@@ -783,7 +814,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type = ComponentType.WechatEmoji
type: ComponentType = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
+55
View File
@@ -1,4 +1,5 @@
from astrbot import logger
from astrbot.api import sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Persona, PersonaFolder, Personality
@@ -58,6 +59,60 @@ class PersonaManager:
except Exception:
return DEFAULT_PERSONALITY
async def resolve_selected_persona(
self,
*,
umo: str | MessageSession,
conversation_persona_id: str | None,
platform_name: str,
provider_settings: dict | None = None,
) -> tuple[str | None, Personality | None, str | None, bool]:
"""解析当前会话最终生效的人格。
Returns:
tuple:
- selected persona_id
- selected persona object
- force applied persona_id from session rule
- whether use webchat special default persona
"""
session_service_config = (
await sp.get_async(
scope="umo",
scope_id=str(umo),
key="session_service_config",
default={},
)
or {}
)
force_applied_persona_id = session_service_config.get("persona_id")
persona_id = force_applied_persona_id
if not persona_id:
persona_id = conversation_persona_id
if persona_id == "[%None]":
pass
elif persona_id is None:
persona_id = (provider_settings or {}).get("default_personality")
persona = next(
(item for item in self.personas_v3 if item["name"] == persona_id),
None,
)
use_webchat_special_default = False
if not persona and platform_name == "webchat" and persona_id != "[%None]":
persona_id = "_chatui_default_"
use_webchat_special_default = True
return (
persona_id,
persona,
force_applied_persona_id,
use_webchat_special_default,
)
async def delete_persona(self, persona_id: str) -> None:
"""删除指定 persona"""
if not await self.db.get_persona_by_id(persona_id):
+77 -21
View File
@@ -1,30 +1,71 @@
"""Pipeline package exports.
This module intentionally avoids eager imports of all pipeline stage modules to
prevent import-time cycles. Stage classes remain available via lazy attribute
resolution for backward compatibility.
"""
from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING, Any
from astrbot.core.message.message_event_result import (
EventResultType,
MessageEventResult,
)
from .content_safety_check.stage import ContentSafetyCheckStage
from .preprocess_stage.stage import PreProcessStage
from .process_stage.stage import ProcessStage
from .rate_limit_check.stage import RateLimitStage
from .respond.stage import RespondStage
from .result_decorate.stage import ResultDecorateStage
from .session_status_check.stage import SessionStatusCheckStage
from .waking_check.stage import WakingCheckStage
from .whitelist_check.stage import WhitelistCheckStage
from .stage_order import STAGES_ORDER
# 管道阶段顺序
STAGES_ORDER = [
"WakingCheckStage", # 检查是否需要唤醒
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
"SessionStatusCheckStage", # 检查会话是否整体启用
"RateLimitStage", # 检查会话是否超过频率限制
"ContentSafetyCheckStage", # 检查内容安全
"PreProcessStage", # 预处理
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
"RespondStage", # 发送消息
]
if TYPE_CHECKING:
from .content_safety_check.stage import ContentSafetyCheckStage
from .preprocess_stage.stage import PreProcessStage
from .process_stage.stage import ProcessStage
from .rate_limit_check.stage import RateLimitStage
from .respond.stage import RespondStage
from .result_decorate.stage import ResultDecorateStage
from .session_status_check.stage import SessionStatusCheckStage
from .waking_check.stage import WakingCheckStage
from .whitelist_check.stage import WhitelistCheckStage
_LAZY_EXPORTS = {
"ContentSafetyCheckStage": (
"astrbot.core.pipeline.content_safety_check.stage",
"ContentSafetyCheckStage",
),
"PreProcessStage": (
"astrbot.core.pipeline.preprocess_stage.stage",
"PreProcessStage",
),
"ProcessStage": (
"astrbot.core.pipeline.process_stage.stage",
"ProcessStage",
),
"RateLimitStage": (
"astrbot.core.pipeline.rate_limit_check.stage",
"RateLimitStage",
),
"RespondStage": (
"astrbot.core.pipeline.respond.stage",
"RespondStage",
),
"ResultDecorateStage": (
"astrbot.core.pipeline.result_decorate.stage",
"ResultDecorateStage",
),
"SessionStatusCheckStage": (
"astrbot.core.pipeline.session_status_check.stage",
"SessionStatusCheckStage",
),
"WakingCheckStage": (
"astrbot.core.pipeline.waking_check.stage",
"WakingCheckStage",
),
"WhitelistCheckStage": (
"astrbot.core.pipeline.whitelist_check.stage",
"WhitelistCheckStage",
),
}
__all__ = [
"ContentSafetyCheckStage",
@@ -36,6 +77,21 @@ __all__ = [
"RespondStage",
"ResultDecorateStage",
"SessionStatusCheckStage",
"STAGES_ORDER",
"WakingCheckStage",
"WhitelistCheckStage",
]
def __getattr__(name: str) -> Any:
if name not in _LAZY_EXPORTS:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_path, attr_name = _LAZY_EXPORTS[name]
module = import_module(module_path)
value = getattr(module, attr_name)
globals()[name] = value
return value
def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
+52
View File
@@ -0,0 +1,52 @@
"""Pipeline bootstrap utilities."""
from importlib import import_module
from .stage import registered_stages
_BUILTIN_STAGE_MODULES = (
"astrbot.core.pipeline.waking_check.stage",
"astrbot.core.pipeline.whitelist_check.stage",
"astrbot.core.pipeline.session_status_check.stage",
"astrbot.core.pipeline.rate_limit_check.stage",
"astrbot.core.pipeline.content_safety_check.stage",
"astrbot.core.pipeline.preprocess_stage.stage",
"astrbot.core.pipeline.process_stage.stage",
"astrbot.core.pipeline.result_decorate.stage",
"astrbot.core.pipeline.respond.stage",
)
_EXPECTED_STAGE_NAMES = {
"WakingCheckStage",
"WhitelistCheckStage",
"SessionStatusCheckStage",
"RateLimitStage",
"ContentSafetyCheckStage",
"PreProcessStage",
"ProcessStage",
"ResultDecorateStage",
"RespondStage",
}
_builtin_stages_registered = False
def ensure_builtin_stages_registered() -> None:
"""Ensure built-in pipeline stages are imported and registered."""
global _builtin_stages_registered
if _builtin_stages_registered:
return
stage_names = {stage_cls.__name__ for stage_cls in registered_stages}
if _EXPECTED_STAGE_NAMES.issubset(stage_names):
_builtin_stages_registered = True
return
for module_path in _BUILTIN_STAGE_MODULES:
import_module(module_path)
_builtin_stages_registered = True
__all__ = ["ensure_builtin_stages_registered"]
+4 -2
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from astrbot.core.config import AstrBotConfig
from astrbot.core.star import PluginManager
from .context_utils import call_event_hook, call_handler
@@ -11,7 +13,7 @@ class PipelineContext:
"""上下文对象,包含管道执行所需的上下文信息"""
astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象
plugin_manager: Any # 插件管理器对象
astrbot_config_id: str
call_handler = call_handler
call_event_hook = call_event_hook
@@ -19,6 +19,7 @@ from astrbot.core.message.message_event_result import (
MessageEventResult,
ResultContentType,
)
from astrbot.core.pipeline.stage import Stage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import (
LLMResponse,
@@ -30,7 +31,6 @@ from astrbot.core.utils.session_lock import session_lock_manager
from .....astr_agent_run_util import run_agent, run_live_agent
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
class InternalAgentSubStage(Stage):
@@ -54,6 +54,7 @@ class InternalAgentSubStage(Stage):
if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
self.show_reasoning = settings.get("display_reasoning_text", False)
self.sanitize_context_by_modalities: bool = settings.get(
"sanitize_context_by_modalities",
@@ -240,6 +241,7 @@ class InternalAgentSubStage(Stage):
tts_provider,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
),
),
@@ -247,13 +249,16 @@ class InternalAgentSubStage(Stage):
yield
# 保存历史记录
if not event.is_stopped() and agent_runner.done():
if agent_runner.done() and (
not event.is_stopped() or agent_runner.was_aborted()
):
await self._save_to_history(
event,
req,
agent_runner.get_final_llm_resp(),
agent_runner.run_context.messages,
agent_runner.stats,
user_aborted=agent_runner.was_aborted(),
)
elif streaming_response and not stream_to_general:
@@ -266,6 +271,7 @@ class InternalAgentSubStage(Stage):
agent_runner,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
),
),
@@ -294,6 +300,7 @@ class InternalAgentSubStage(Stage):
agent_runner,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
stream_to_general,
show_reasoning=self.show_reasoning,
):
@@ -308,13 +315,14 @@ class InternalAgentSubStage(Stage):
)
# 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped():
if not event.is_stopped() or agent_runner.was_aborted():
await self._save_to_history(
event,
req,
final_resp,
agent_runner.run_context.messages,
agent_runner.stats,
user_aborted=agent_runner.was_aborted(),
)
asyncio.create_task(
@@ -340,16 +348,29 @@ class InternalAgentSubStage(Stage):
llm_response: LLMResponse | None,
all_messages: list[Message],
runner_stats: AgentStats | None,
user_aborted: bool = False,
) -> None:
if (
not req
or not req.conversation
or not llm_response
or llm_response.role != "assistant"
):
if not req or not req.conversation:
return
if not llm_response.completion_text and not req.tool_calls_result:
if not llm_response and not user_aborted:
return
if llm_response and llm_response.role != "assistant":
if not user_aborted:
return
llm_response = LLMResponse(
role="assistant",
completion_text=llm_response.completion_text or "",
)
elif llm_response is None:
llm_response = LLMResponse(role="assistant", completion_text="")
if (
not llm_response.completion_text
and not req.tool_calls_result
and not user_aborted
):
logger.debug("LLM 响应为空,不保存记录。")
return
@@ -363,6 +384,14 @@ class InternalAgentSubStage(Stage):
continue
message_to_save.append(message.model_dump())
# if user_aborted:
# message_to_save.append(
# Message(
# role="assistant",
# content="[User aborted this request. Partial output before abort was preserved.]",
# ).model_dump()
# )
token_usage = None
if runner_stats:
# token_usage = runner_stats.token_usage.total
@@ -8,6 +8,7 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
DashscopeAgentRunner,
)
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.message.components import Image
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -17,6 +18,7 @@ from astrbot.core.message.message_event_result import (
if TYPE_CHECKING:
from astrbot.core.agent.runners.base import BaseAgentRunner
from astrbot.core.pipeline.stage import Stage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import (
ProviderRequest,
@@ -25,9 +27,7 @@ from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
AGENT_RUNNER_TYPE_KEY = {
"dify": "dify_agent_runner_provider_id",
@@ -8,9 +8,9 @@ from astrbot.core import logger
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
from ...context import PipelineContext, call_handler
from ...context import PipelineContext, call_event_hook, call_handler
from ..stage import Stage
@@ -48,10 +48,20 @@ class StarRequestSubStage(Stage):
yield ret
event.clear_result() # 清除上一个 handler 的结果
except Exception as e:
logger.error(traceback.format_exc())
traceback_text = traceback.format_exc()
logger.error(traceback_text)
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
if event.is_at_or_wake_command:
await call_event_hook(
event,
EventType.OnPluginErrorEvent,
md.name,
handler.handler_name,
e,
traceback_text,
)
if not event.is_stopped() and event.is_at_or_wake_command:
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
event.set_result(MessageEventResult().message(ret))
yield
+15
View File
@@ -33,6 +33,21 @@ class RespondStage(Stage):
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
Comp.Music: lambda comp: (
(comp.id and comp._type and comp._type != "custom")
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
), # 音乐分享
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
Comp.Location: lambda comp: bool(
comp.lat is not None and comp.lon is not None
), # 位置
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
Comp.Dice: lambda _: True, # 掷骰子魔法表情
Comp.RPS: lambda _: True, # 猜拳魔法表情
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
}
async def initialize(self, ctx: PipelineContext) -> None:
@@ -315,6 +315,7 @@ class ResultDecorateStage(Stage):
Record(
file=url or audio_path,
url=url or audio_path,
text=comp.text,
),
)
if dual_output:
+13 -6
View File
@@ -6,16 +6,19 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
WecomAIBotMessageEvent,
)
from astrbot.core.utils.active_event_registry import active_event_registry
from . import STAGES_ORDER
from .bootstrap import ensure_builtin_stages_registered
from .context import PipelineContext
from .stage import registered_stages
from .stage_order import STAGES_ORDER
class PipelineScheduler:
"""管道调度器,负责调度各个阶段的执行"""
def __init__(self, context: PipelineContext) -> None:
ensure_builtin_stages_registered()
registered_stages.sort(
key=lambda x: STAGES_ORDER.index(x.__name__),
) # 按照顺序排序
@@ -79,10 +82,14 @@ class PipelineScheduler:
event (AstrMessageEvent): 事件对象
"""
await self._process_stages(event)
active_event_registry.register(event)
try:
await self._process_stages(event)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline 执行完毕。")
logger.debug("pipeline 执行完毕。")
finally:
active_event_registry.unregister(event)
+15
View File
@@ -0,0 +1,15 @@
"""Pipeline stage execution order."""
STAGES_ORDER = [
"WakingCheckStage", # 检查是否需要唤醒
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
"SessionStatusCheckStage", # 检查会话是否整体启用
"RateLimitStage", # 检查会话是否超过频率限制
"ContentSafetyCheckStage", # 检查内容安全
"PreProcessStage", # 预处理
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
"RespondStage", # 发送消息
]
__all__ = ["STAGES_ORDER"]
+33 -11
View File
@@ -52,9 +52,19 @@ class AstrMessageEvent(abc.ABC):
self.is_at_or_wake_command = False
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
self._extras: dict[str, Any] = {}
message_type = getattr(message_obj, "type", None)
if not isinstance(message_type, MessageType):
try:
message_type = MessageType(str(message_type))
except (ValueError, TypeError, AttributeError):
logger.warning(
f"Failed to convert message type {message_obj.type!r} to MessageType. "
f"Falling back to FRIEND_MESSAGE."
)
message_type = MessageType.FRIEND_MESSAGE
self.session = MessageSession(
platform_name=platform_meta.id,
message_type=message_obj.type,
message_type=message_type,
session_id=session_id,
)
# self.unified_msg_origin = str(self.session)
@@ -159,15 +169,18 @@ class AstrMessageEvent(abc.ABC):
除了文本消息外其他消息类型会被转换为对应的占位符如图片消息会被转换为 [图片]
"""
return self._outline_chain(self.message_obj.message)
return self._outline_chain(getattr(self.message_obj, "message", None))
def get_messages(self) -> list[BaseMessageComponent]:
"""获取消息链。"""
return self.message_obj.message
return getattr(self.message_obj, "message", [])
def get_message_type(self) -> MessageType:
"""获取消息类型。"""
return self.message_obj.type
message_type = getattr(self.message_obj, "type", None)
if isinstance(message_type, MessageType):
return message_type
return self.session.message_type
def get_session_id(self) -> str:
"""获取会话id。"""
@@ -175,21 +188,30 @@ class AstrMessageEvent(abc.ABC):
def get_group_id(self) -> str:
"""获取群组id。如果不是群组消息,返回空字符串。"""
return self.message_obj.group_id
return getattr(self.message_obj, "group_id", "")
def get_self_id(self) -> str:
"""获取机器人自身的id。"""
return self.message_obj.self_id
return getattr(self.message_obj, "self_id", "")
def get_sender_id(self) -> str:
"""获取消息发送者的id。"""
return self.message_obj.sender.user_id
sender = getattr(self.message_obj, "sender", None)
if sender and isinstance(getattr(sender, "user_id", None), str):
return sender.user_id
return ""
def get_sender_name(self) -> str:
"""获取消息发送者的名称。(可能会返回空字符串)"""
if isinstance(self.message_obj.sender.nickname, str):
return self.message_obj.sender.nickname
return ""
sender = getattr(self.message_obj, "sender", None)
if not sender:
return ""
nickname = getattr(sender, "nickname", None)
if nickname is None:
return ""
if isinstance(nickname, str):
return nickname
return str(nickname)
def set_extra(self, key, value) -> None:
"""设置额外的信息。"""
@@ -208,7 +230,7 @@ class AstrMessageEvent(abc.ABC):
def is_private_chat(self) -> bool:
"""是否是私聊。"""
return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value
return self.get_message_type() == MessageType.FRIEND_MESSAGE
def is_wake_up(self) -> bool:
"""是否是唤醒机器人的事件。"""
@@ -45,6 +45,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
if isinstance(segment, File):
# For File segments, we need to handle the file differently
d = await segment.to_dict()
file_val = d.get("data", {}).get("file", "")
if file_val:
import pathlib
try:
# 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异
path_obj = pathlib.Path(file_val)
# 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI
if path_obj.is_absolute() and "://" not in file_val:
d["data"]["file"] = path_obj.as_uri()
except Exception:
# 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换
pass
return d
if isinstance(segment, Video):
d = await segment.to_dict()
@@ -1,4 +1,5 @@
import asyncio
import inspect
import itertools
import logging
import time
@@ -436,7 +437,42 @@ class AiocqhttpAdapter(Platform):
return coro
async def terminate(self) -> None:
self.shutdown_event.set()
if hasattr(self, "shutdown_event"):
self.shutdown_event.set()
await self._close_reverse_ws_connections()
async def _close_reverse_ws_connections(self) -> None:
api_clients = getattr(self.bot, "_wsr_api_clients", None)
event_clients = getattr(self.bot, "_wsr_event_clients", None)
ws_clients: set[Any] = set()
if isinstance(api_clients, dict):
ws_clients.update(api_clients.values())
if isinstance(event_clients, set):
ws_clients.update(event_clients)
close_tasks: list[Awaitable[Any]] = []
for ws in ws_clients:
close_func = getattr(ws, "close", None)
if not callable(close_func):
continue
try:
close_result = close_func(code=1000, reason="Adapter shutdown")
except TypeError:
close_result = close_func()
except Exception:
continue
if inspect.isawaitable(close_result):
close_tasks.append(close_result)
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
if isinstance(api_clients, dict):
api_clients.clear()
if isinstance(event_clients, set):
event_clients.clear()
async def shutdown_trigger_placeholder(self) -> None:
await self.shutdown_event.wait()
@@ -65,15 +65,6 @@ LINE_I18N_RESOURCES = {
"line",
"LINE Messaging API 适配器",
support_streaming_message=False,
default_config_tmpl={
"id": "line",
"type": "line",
"enable": False,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
},
config_metadata=LINE_CONFIG_METADATA,
i18n_resources=LINE_I18N_RESOURCES,
)
@@ -7,13 +7,14 @@ from typing import cast
import aiofiles
import botpy
import botpy.errors
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from botpy.http import Route
from botpy.types import message
from botpy.types.message import Media
from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -24,7 +25,29 @@ from astrbot.core.utils.io import download_image_by_url, file_to_base64
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
def _patch_qq_botpy_formdata() -> None:
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects
aiohttp.FormData to have a private flag named _is_processed, which is no
longer present in newer aiohttp versions.
"""
try:
from botpy.http import _FormData # type: ignore
if not hasattr(_FormData, "_is_processed"):
setattr(_FormData, "_is_processed", False)
except Exception:
logger.debug("[QQOfficial] Skip botpy FormData patch.")
_patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
def __init__(
self,
message_str: str,
@@ -114,7 +137,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return None
payload: dict = {
"content": plain_text,
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
"msg_type": 2,
"msg_id": self.message_obj.message_id,
}
@@ -137,6 +162,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_record(
record_file_path,
@@ -145,9 +172,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.bot.api.post_group_message(
group_openid=source.group_openid,
**payload,
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case botpy.message.C2CMessage():
@@ -159,6 +192,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_record(
record_file_path,
@@ -167,31 +202,56 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream:
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
stream=stream,
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
stream=stream,
),
payload=payload,
plain_text=plain_text,
)
else:
ret = await self.post_c2c_message(
openid=source.author.user_openid,
**payload,
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
openid=source.author.user_openid,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
logger.debug(f"Message sent to C2C: {ret}")
case botpy.message.Message():
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_message(
channel_id=source.channel_id,
**payload,
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
payload.pop("msg_type", None)
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_message(
channel_id=source.channel_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case botpy.message.DirectMessage():
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
# Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type.
payload.pop("msg_type", None)
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_dms(
guild_id=source.guild_id,
**retry_payload,
),
payload=payload,
plain_text=plain_text,
)
case _:
pass
@@ -202,6 +262,32 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return ret
async def _send_with_markdown_fallback(
self,
send_func,
payload: dict,
plain_text: str,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
or not plain_text
):
raise
logger.warning(
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload["markdown"] = None
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
self,
image_base64: str,
@@ -1,7 +1,9 @@
import asyncio
import os
import re
import sys
import uuid
from typing import cast
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import BotCommand, Update
@@ -25,6 +27,9 @@ from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.media_utils import convert_audio_to_wav
from .tg_event import TelegramPlatformEvent
@@ -174,14 +179,19 @@ class TelegramPlatformAdapter(Platform):
if not handler_metadata.enabled:
continue
for event_filter in handler_metadata.event_filters:
cmd_info = self._extract_command_info(
cmd_info_list = self._extract_command_info(
event_filter,
handler_metadata,
skip_commands,
)
if cmd_info:
cmd_name, description = cmd_info
command_dict.setdefault(cmd_name, description)
if cmd_info_list:
for cmd_name, description in cmd_info_list:
if cmd_name in command_dict:
logger.warning(
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"'{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
commands_a = sorted(command_dict.keys())
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
@@ -191,9 +201,9 @@ class TelegramPlatformAdapter(Platform):
event_filter,
handler_metadata,
skip_commands: set,
) -> tuple[str, str] | None:
"""从事件过滤器中提取指令信息"""
cmd_name = None
) -> list[tuple[str, str]] | None:
"""从事件过滤器中提取指令信息,包括所有别名"""
cmd_names = []
is_group = False
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
if (
@@ -201,26 +211,32 @@ class TelegramPlatformAdapter(Platform):
and event_filter.parent_command_names != [""]
):
return None
cmd_name = event_filter.command_name
# 收集主命令名和所有别名
cmd_names = [event_filter.command_name]
if event_filter.alias:
cmd_names.extend(event_filter.alias)
elif isinstance(event_filter, CommandGroupFilter):
if event_filter.parent_group:
return None
cmd_name = event_filter.group_name
cmd_names = [event_filter.group_name]
is_group = True
if not cmd_name or cmd_name in skip_commands:
return None
result = []
for cmd_name in cmd_names:
if not cmd_name or cmd_name in skip_commands:
continue
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
continue
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
return None
# Build description.
description = handler_metadata.desc or (
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
)
if len(description) > 30:
description = description[:30] + "..."
result.append((cmd_name, description))
# Build description.
description = handler_metadata.desc or (
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
)
if len(description) > 30:
description = description[:30] + "..."
return cmd_name, description
return result if result else None
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
@@ -364,8 +380,19 @@ class TelegramPlatformAdapter(Platform):
elif update.message.voice:
file = await update.message.voice.get_file()
file_basename = os.path.basename(cast(str, file.file_path))
temp_dir = get_astrbot_temp_path()
temp_path = os.path.join(temp_dir, file_basename)
await download_file(cast(str, file.file_path), path=temp_path)
path_wav = os.path.join(
temp_dir,
f"{file_basename}.wav",
)
path_wav = await convert_audio_to_wav(temp_path, path_wav)
message.message = [
Comp.Record(file=file.file_path, url=file.file_path),
Comp.Record(file=path_wav, url=path_wav),
]
elif update.message.photo:
@@ -6,6 +6,7 @@ from typing import Any, cast
import telegramify_markdown
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import ExtBot
from astrbot import logger
@@ -17,6 +18,7 @@ from astrbot.api.message_components import (
Plain,
Record,
Reply,
Video,
)
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
@@ -35,6 +37,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
# 消息类型到 chat action 的映射,用于优先级判断
ACTION_BY_TYPE: dict[type, str] = {
Record: ChatAction.UPLOAD_VOICE,
Video: ChatAction.UPLOAD_VIDEO,
File: ChatAction.UPLOAD_DOCUMENT,
Image: ChatAction.UPLOAD_PHOTO,
Plain: ChatAction.TYPING,
@@ -113,11 +116,82 @@ class TelegramPlatformEvent(AstrMessageEvent):
**payload: Any,
) -> None:
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
await send_coro(**payload)
await cls._send_chat_action(
client, user_name, ChatAction.TYPING, message_thread_id
effective_thread_id = message_thread_id or cast(
str | None, payload.get("message_thread_id")
)
await cls._send_chat_action(
client, user_name, upload_action, effective_thread_id
)
send_payload = dict(payload)
if effective_thread_id and "message_thread_id" not in send_payload:
send_payload["message_thread_id"] = effective_thread_id
await send_coro(**send_payload)
await cls._send_chat_action(
client, user_name, ChatAction.TYPING, effective_thread_id
)
@classmethod
async def _send_voice_with_fallback(
cls,
client: ExtBot,
path: str,
payload: dict[str, Any],
*,
caption: str | None = None,
user_name: str = "",
message_thread_id: str | None = None,
use_media_action: bool = False,
) -> None:
"""Send a voice message, falling back to a document if the user's
privacy settings forbid voice messages (``BadRequest`` with
``Voice_messages_forbidden``).
When *use_media_action* is ``True`` the helper wraps the send calls
with ``_send_media_with_action`` (used by the streaming path).
"""
try:
if use_media_action:
media_payload = dict(payload)
if message_thread_id and "message_thread_id" not in media_payload:
media_payload["message_thread_id"] = message_thread_id
await cls._send_media_with_action(
client,
ChatAction.UPLOAD_VOICE,
client.send_voice,
user_name=user_name,
voice=path,
**cast(Any, media_payload),
)
else:
await client.send_voice(voice=path, **cast(Any, payload))
except BadRequest as e:
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
# distinguish the voice-privacy case via the API error message.
if "Voice_messages_forbidden" not in e.message:
raise
logger.warning(
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
)
if use_media_action:
media_payload = dict(payload)
if message_thread_id and "message_thread_id" not in media_payload:
media_payload["message_thread_id"] = message_thread_id
await cls._send_media_with_action(
client,
ChatAction.UPLOAD_DOCUMENT,
client.send_document,
user_name=user_name,
document=path,
caption=caption,
**cast(Any, media_payload),
)
else:
await client.send_document(
document=path,
caption=caption,
**cast(Any, payload),
)
async def _ensure_typing(
self,
@@ -211,7 +285,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await client.send_voice(voice=path, **cast(Any, payload))
await cls._send_voice_with_fallback(
client,
path,
payload,
caption=i.text or None,
use_media_action=False,
)
elif isinstance(i, Video):
path = await i.convert_to_file_path()
await client.send_video(
video=path,
caption=getattr(i, "text", None) or None,
**cast(Any, payload),
)
async def send(self, message: MessageChain) -> None:
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -267,7 +354,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
"chat_id": user_name,
}
if message_thread_id:
payload["reply_to_message_id"] = message_thread_id
payload["message_thread_id"] = message_thread_id
delta = ""
current_content = ""
@@ -309,7 +396,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
message_thread_id=message_thread_id,
photo=image_path,
**cast(Any, payload),
)
@@ -322,7 +408,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
ChatAction.UPLOAD_DOCUMENT,
self.client.send_document,
user_name=user_name,
message_thread_id=message_thread_id,
document=path,
filename=name,
**cast(Any, payload),
@@ -330,13 +415,24 @@ class TelegramPlatformEvent(AstrMessageEvent):
continue
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self._send_media_with_action(
await self._send_voice_with_fallback(
self.client,
ChatAction.UPLOAD_VOICE,
self.client.send_voice,
path,
payload,
caption=i.text or delta or None,
user_name=user_name,
message_thread_id=message_thread_id,
voice=path,
use_media_action=True,
)
continue
elif isinstance(i, Video):
path = await i.convert_to_file_path()
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_VIDEO,
self.client.send_video,
user_name=user_name,
video=path,
**cast(Any, payload),
)
continue
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
class WebChatMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
os.makedirs(imgs_dir, exist_ok=True)
os.makedirs(attachments_dir, exist_ok=True)
@staticmethod
async def _send(
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Image):
# save image to local
filename = f"{str(uuid.uuid4())}.jpg"
path = os.path.join(imgs_dir, filename)
path = os.path.join(attachments_dir, filename)
image_base64 = await comp.convert_to_base64()
with open(path, "wb") as f:
f.write(base64.b64decode(image_base64))
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Record):
# save record to local
filename = f"{str(uuid.uuid4())}.wav"
path = os.path.join(imgs_dir, filename)
path = os.path.join(attachments_dir, filename)
record_base64 = await comp.convert_to_base64()
with open(path, "wb") as f:
f.write(base64.b64decode(record_base64))
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
original_name = comp.name or os.path.basename(file_path)
ext = os.path.splitext(original_name)[1] or ""
filename = f"{uuid.uuid4()!s}{ext}"
dest_path = os.path.join(imgs_dir, filename)
dest_path = os.path.join(attachments_dir, filename)
shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}"
await web_chat_back_queue.put(
@@ -1,13 +1,14 @@
import asyncio
import os
import sys
import time
import uuid
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from typing import Any, cast
import quart
from requests import Response
from wechatpy import WeChatClient, parse_message
from wechatpy import WeChatClient, create_reply, parse_message
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
@@ -38,7 +39,12 @@ else:
class WeixinOfficialAccountServer:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
def __init__(
self,
event_queue: asyncio.Queue,
config: dict,
user_buffer: dict[Any, dict[str, Any]],
) -> None:
self.server = quart.Quart(__name__)
self.port = int(cast(int | str, config.get("port")))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
@@ -59,9 +65,15 @@ class WeixinOfficialAccountServer:
self.event_queue = event_queue
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.callback: (
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
) = None
self.shutdown_event = asyncio.Event()
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
async def verify(self):
"""内部服务器的 GET 验证入口"""
return await self.handle_verify(quart.request)
@@ -98,6 +110,22 @@ class WeixinOfficialAccountServer:
"""内部服务器的 POST 回调入口"""
return await self.handle_callback(quart.request)
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
if xml and "<Encrypt>" not in xml and nonce and timestamp:
return self.crypto.encrypt_message(xml, nonce, timestamp)
return xml or "success"
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
"""生成消息预览文本,供占位符使用"""
if isinstance(msg, TextMessage):
t = cast(str, msg.content).strip()
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
if isinstance(msg, ImageMessage):
return "图片"
if isinstance(msg, VoiceMessage):
return "语音"
return getattr(msg, "type", "未知消息")
async def handle_callback(self, request) -> str:
"""处理回调请求,可被统一 webhook 入口复用
@@ -123,14 +151,152 @@ class WeixinOfficialAccountServer:
raise
logger.info(f"解析成功: {msg}")
if self.callback:
if not self.callback:
return "success"
# by pass passive reply logic and return active reply directly.
if self.active_send_mode:
result_xml = await self.callback(msg)
if not result_xml:
return "success"
if isinstance(result_xml, str):
return result_xml
return "success"
# passive reply
from_user = str(getattr(msg, "source", ""))
msg_id = str(cast(str | int, getattr(msg, "id", "")))
state = self.user_buffer.get(from_user)
def _reply_text(text: str) -> str:
reply_obj = create_reply(text, msg)
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
return self._maybe_encrypt(reply_xml, nonce, timestamp)
# if in cached state, return cached result or placeholder
if state:
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit on trigger: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
placeholder = (
f"【正在思考'{state.get('preview', '...')}'中,已思考"
f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
)
# same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
if task and state.get("msg_id") == msg_id:
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml")
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(
f"wx buffer hit on retry window: user={from_user}"
)
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
logger.debug(
f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} "
)
return _reply_text(cached_xml)
else:
logger.debug(
f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} "
)
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical(
"wx task failed in passive window", exc_info=True
)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
)
return _reply_text(placeholder)
logger.debug(f"wx trigger while thinking: user={from_user}")
return _reply_text(placeholder)
# create new trigger when state is empty, and store state in buffer
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
preview = self._preview(msg)
placeholder = (
f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
)
logger.info(
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
)
self.user_buffer[from_user] = state = {
"msg_id": msg_id,
"preview": preview,
"task": None, # set later after task created
"cached_xml": [], # for passive reply
"started_at": time.monotonic(),
}
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
self.callback(msg)
)
# immediate return if done
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
return_when=asyncio.FIRST_COMPLETED,
)
if done:
try:
cached = state.get("cached_xml", None)
# send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit immediately: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
self.user_buffer.pop(from_user, None)
return _reply_text(cached_xml)
else:
return _reply_text(
cached_xml
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
logger.info(
f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} "
)
return _reply_text(placeholder)
except Exception:
logger.critical("wx task failed in first window", exc_info=True)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
return _reply_text(placeholder)
async def start_polling(self) -> None:
logger.info(
@@ -176,7 +342,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
self.server = WeixinOfficialAccountServer(
self._event_queue, self.config, self.user_buffer
)
self.client = WeChatClient(
self.config["appid"].strip(),
@@ -193,28 +362,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
try:
if self.active_send_mode:
await self.convert_message(msg, None)
return None
msg_id = str(cast(str | int, msg.id))
future = self.wexin_event_workers.get(msg_id)
if future:
logger.debug(f"duplicate message id checked: {msg.id}")
else:
if str(msg.id) in self.wexin_event_workers:
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
await self.convert_message(msg, future)
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[msg_id] = future
await self.convert_message(msg, future)
# I love shield so much!
result = await asyncio.wait_for(
asyncio.shield(future),
60,
) # wait for 60s
logger.debug(f"Got future result: {result}")
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
return result # xml. see weixin_offacc_event.py
180,
) # wait for 180s
logger.debug(f"Got future result: {result}")
return result
except asyncio.TimeoutError:
pass
logger.info(f"callback 处理消息超时: message_id={msg.id}")
return create_reply("处理消息超时,请稍后再试。", msg)
except Exception as e:
logger.error(f"转换消息时出现异常: {e}")
finally:
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
self.server.callback = callback
self.server.active_send_mode = self.active_send_mode
@override
async def send_by_session(
@@ -336,12 +510,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
await self.handle_msg(abm)
async def handle_msg(self, message: AstrBotMessage) -> None:
buffer = self.user_buffer.get(message.sender.user_id, None)
if buffer is None:
logger.critical(
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
)
return
message_event = WeixinOfficialAccountPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
message_out=buffer,
)
self.commit_event(message_event)
@@ -1,9 +1,9 @@
import asyncio
import os
from typing import cast
from typing import Any, cast
from wechatpy import WeChatClient
from wechatpy.replies import ImageReply, TextReply, VoiceReply
from wechatpy.replies import ImageReply, VoiceReply
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
platform_meta: PlatformMetadata,
session_id: str,
client: WeChatClient,
message_out: dict[Any, Any],
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
self.message_out = message_out
@staticmethod
async def send_with_client(
@@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
) -> None:
pass
async def split_plain(self, plain: str) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
Args:
plain (str): 要分割的长文本
@@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
list[str]: 分割后的文本列表
"""
if len(plain) <= 2048:
if len(plain) <= max_length:
return [plain]
result = []
start = 0
while start < len(plain):
# 剩下的字符串长度<2048时结束
if start + 2048 >= len(plain):
# 剩下的字符串长度<max_length时结束
if start + max_length >= len(plain):
result.append(plain[start:])
break
# 向前搜索分割标点符号
end = min(start + 2048, len(plain))
end = min(start + max_length, len(plain))
cut_position = end
for i in range(end, start, -1):
if i < len(plain) and plain[i - 1] in [
@@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
if isinstance(comp, Plain):
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks:
if active_send_mode:
if active_send_mode:
for chunk in plain_chunks:
self.client.message.send_text(message_obj.sender.user_id, chunk)
else:
reply = TextReply(
content=chunk,
message=cast(dict, self.message_obj.raw_message)["message"],
)
xml = reply.render()
future = cast(dict, self.message_obj.raw_message)["future"]
assert isinstance(future, asyncio.Future)
future.set_result(xml)
await asyncio.sleep(0.5) # Avoid sending too fast
else:
# disable passive sending, just store the chunks in
logger.debug(
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
)
self.message_out["cached_xml"] = plain_chunks
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
+10
View File
@@ -295,6 +295,16 @@ class ProviderManager:
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "xai_chat_completion":
from .sources.xai_source import ProviderXAI as ProviderXAI
case "aihubmix_chat_completion":
from .sources.oai_aihubmix_source import (
ProviderAIHubMix as ProviderAIHubMix,
)
case "openrouter_chat_completion":
from .sources.openrouter_source import (
ProviderOpenRouter as ProviderOpenRouter,
)
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
@@ -33,20 +33,29 @@ class ProviderAnthropic(Provider):
self,
provider_config,
provider_settings,
*,
use_api_key: bool = True,
) -> None:
super().__init__(
provider_config,
provider_settings,
)
self.chosen_api_key: str = ""
self.api_keys: list = super().get_keys()
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.thinking_config = provider_config.get("anth_thinking_config", {})
if use_api_key:
self._init_api_key(provider_config)
self.set_model(provider_config.get("model", "unknown"))
def _init_api_key(self, provider_config: dict) -> None:
self.chosen_api_key: str = ""
self.api_keys: list = super().get_keys()
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.client = AsyncAnthropic(
api_key=self.chosen_api_key,
timeout=self.timeout,
@@ -54,15 +63,27 @@ class ProviderAnthropic(Provider):
http_client=self._create_http_client(provider_config),
)
self.thinking_config = provider_config.get("anth_thinking_config", {})
self.set_model(provider_config.get("model", "unknown"))
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
"""创建带代理的 HTTP 客户端"""
proxy = provider_config.get("proxy", "")
return create_proxy_client("Anthropic", proxy)
def _apply_thinking_config(self, payloads: dict) -> None:
thinking_type = self.thinking_config.get("type", "")
if thinking_type == "adaptive":
payloads["thinking"] = {"type": "adaptive"}
effort = self.thinking_config.get("effort", "")
output_cfg = dict(payloads.get("output_config", {}))
if effort:
output_cfg["effort"] = effort
if output_cfg:
payloads["output_config"] = output_cfg
elif not thinking_type and self.thinking_config.get("budget"):
payloads["thinking"] = {
"budget_tokens": self.thinking_config.get("budget"),
"type": "enabled",
}
def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload
@@ -213,11 +234,7 @@ class ProviderAnthropic(Provider):
if "max_tokens" not in payloads:
payloads["max_tokens"] = 1024
if self.thinking_config.get("budget"):
payloads["thinking"] = {
"budget_tokens": self.thinking_config.get("budget"),
"type": "enabled",
}
self._apply_thinking_config(payloads)
try:
completion = await self.client.messages.create(
@@ -287,11 +304,7 @@ class ProviderAnthropic(Provider):
if "max_tokens" not in payloads:
payloads["max_tokens"] = 1024
if self.thinking_config.get("budget"):
payloads["thinking"] = {
"budget_tokens": self.thinking_config.get("budget"),
"type": "enabled",
}
self._apply_thinking_config(payloads)
async with self.client.messages.stream(
**payloads, extra_body=extra_body
@@ -48,6 +48,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
result = await self.client.models.embed_content(
model=self.model,
contents=text,
config=types.EmbedContentConfig(
output_dimensionality=self.get_dim(),
),
)
assert result.embeddings is not None
assert result.embeddings[0].values is not None
@@ -61,6 +64,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
result = await self.client.models.embed_content(
model=self.model,
contents=cast(types.ContentListUnion, text),
config=types.EmbedContentConfig(
output_dimensionality=self.get_dim(),
),
)
assert result.embeddings is not None
@@ -0,0 +1,17 @@
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter(
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
)
class ProviderAIHubMix(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# Reference to: https://aihubmix.com/appstore
# Use this code can enjoy 10% off prices for AIHubMix API calls.
self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore
@@ -23,12 +23,16 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
if proxy:
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
http_client = httpx.AsyncClient(proxy=proxy)
api_base = provider_config.get("embedding_api_base", "").strip()
if not api_base:
api_base = "https://api.openai.com/v1"
else:
api_base = api_base.removesuffix("/")
if not api_base.endswith("/v1"):
api_base = f"{api_base}/v1"
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),
base_url=provider_config.get(
"embedding_api_base",
"https://api.openai.com/v1",
),
base_url=api_base,
timeout=int(provider_config.get("timeout", 20)),
http_client=http_client,
)
@@ -36,12 +40,20 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
async def get_embedding(self, text: str) -> list[float]:
"""获取文本的嵌入"""
embedding = await self.client.embeddings.create(input=text, model=self.model)
embedding = await self.client.embeddings.create(
input=text,
model=self.model,
dimensions=self.get_dim(),
)
return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入"""
embeddings = await self.client.embeddings.create(input=text, model=self.model)
embeddings = await self.client.embeddings.create(
input=text,
model=self.model,
dimensions=self.get_dim(),
)
return [item.embedding for item in embeddings.data]
def get_dim(self) -> int:
+12 -2
View File
@@ -381,13 +381,22 @@ class ProviderOpenAIOfficial(Provider):
plain string. This method handles both formats.
Args:
raw_content: The raw content from LLM response, can be str, list, or other.
raw_content: The raw content from LLM response, can be str, list, dict, or other.
strip: Whether to strip whitespace from the result. Set to False for
streaming chunks to preserve spaces between words.
Returns:
Normalized plain text string.
"""
# Handle dict format (e.g., {"type": "text", "text": "..."})
if isinstance(raw_content, dict):
if "text" in raw_content:
text_val = raw_content.get("text", "")
return str(text_val) if text_val is not None else ""
# For other dict formats, return empty string and log
logger.warning(f"Unexpected dict format content: {raw_content}")
return ""
if isinstance(raw_content, list):
# Check if this looks like OpenAI content-part format
# Only process if at least one item has {'type': 'text', 'text': ...} structure
@@ -450,7 +459,8 @@ class ProviderOpenAIOfficial(Provider):
return "".join(text_parts)
return content
return str(raw_content)
# Fallback for other types (int, float, etc.)
return str(raw_content) if raw_content is not None else ""
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
@@ -0,0 +1,19 @@
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter(
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
)
class ProviderOpenRouter(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# Reference to: https://openrouter.ai/docs/api/reference/overview#headers
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot"
)
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
@@ -7,12 +7,14 @@ import asyncio
import os
import re
from datetime import datetime
from pathlib import Path
from typing import cast
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
@@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
async def get_timestamped_path(self) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return os.path.join("data", "temp", f"{timestamp}")
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
return str(temp_dir / timestamp)
async def _is_silk_file(self, file_path) -> bool:
silk_header = b"SILK"
+13 -62
View File
@@ -1,68 +1,19 @@
from astrbot.core import html_renderer
# 兼容导出: Provider 从 provider 模块重新导出
from astrbot.core.provider import Provider
from astrbot.core.star.star_tools import StarTools
from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .base import Star
from .context import Context
from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager
from .star_tools import StarTools
class Star(CommandParserMixin, PluginKVStoreMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
def __init__(self, context: Context, config: dict | None = None) -> None:
StarTools.initialize(context)
self.context = context
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not star_map.get(cls.__module__):
metadata = StarMetadata(
star_cls_type=cls,
module_path=cls.__module__,
)
star_map[cls.__module__] = metadata
star_registry.append(metadata)
else:
star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__
async def text_to_image(self, text: str, return_url=True) -> str:
"""将文本转换为图片"""
return await html_renderer.render_t2i(
text,
return_url=return_url,
template_name=self.context._config.get("t2i_active_template"),
)
async def html_render(
self,
tmpl: str,
data: dict,
return_url=True,
options: dict | None = None,
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(
tmpl,
data,
return_url=return_url,
options=options,
)
async def initialize(self) -> None:
"""当插件被激活时会调用这个方法"""
async def terminate(self) -> None:
"""当插件被禁用、重载插件时会调用这个方法"""
def __del__(self) -> None:
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"]
__all__ = [
"Context",
"PluginManager",
"Provider",
"Star",
"StarMetadata",
"StarTools",
"star_map",
"star_registry",
]
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import logging
from typing import Any, Protocol
from astrbot.core import html_renderer
from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .star import StarMetadata, star_map, star_registry
logger = logging.getLogger("astrbot")
class Star(CommandParserMixin, PluginKVStoreMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
class _ContextLike(Protocol):
def get_config(self, umo: str | None = None) -> Any: ...
def __init__(self, context: _ContextLike, config: dict | None = None) -> None:
self.context = context
def _get_context_config(self) -> Any:
get_config = getattr(self.context, "get_config", None)
if callable(get_config):
try:
return get_config()
except Exception as e:
logger.debug(f"get_config() failed: {e}")
return None
return getattr(self.context, "_config", None)
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not star_map.get(cls.__module__):
metadata = StarMetadata(
star_cls_type=cls,
module_path=cls.__module__,
)
star_map[cls.__module__] = metadata
star_registry.append(metadata)
else:
star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__
async def text_to_image(self, text: str, return_url=True) -> str:
"""将文本转换为图片"""
config_obj = self._get_context_config()
template_name = None
if hasattr(config_obj, "get"):
try:
template_name = config_obj.get("t2i_active_template")
except Exception:
template_name = None
return await html_renderer.render_t2i(
text,
return_url=return_url,
template_name=template_name,
)
async def html_render(
self,
tmpl: str,
data: dict,
return_url=True,
options: dict | None = None,
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(
tmpl,
data,
return_url=return_url,
options=options,
)
async def initialize(self) -> None:
"""当插件被激活时会调用这个方法"""
async def terminate(self) -> None:
"""当插件被禁用、重载插件时会调用这个方法"""
def __del__(self) -> None:
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
+16 -4
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import logging
from asyncio import Queue
from collections.abc import Awaitable, Callable
from typing import Any
from typing import TYPE_CHECKING, Any, Protocol
from deprecated import deprecated
@@ -12,14 +14,12 @@ from astrbot.core.agent.tool import ToolSet
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.cron.manager import CronJobManager
from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.platform import Platform
from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
@@ -45,6 +45,15 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
logger = logging.getLogger("astrbot")
if TYPE_CHECKING:
from astrbot.core.cron.manager import CronJobManager
else:
CronJobManager = Any
class PlatformManagerProtocol(Protocol):
platform_insts: list[Platform]
class Context:
"""暴露给插件的接口上下文。"""
@@ -61,7 +70,7 @@ class Context:
config: AstrBotConfig,
db: BaseDatabase,
provider_manager: ProviderManager,
platform_manager: PlatformManager,
platform_manager: PlatformManagerProtocol,
conversation_manager: ConversationManager,
message_history_manager: PlatformMessageHistoryManager,
persona_manager: PersonaManager,
@@ -448,6 +457,9 @@ class Context:
if platform.meta().id == session.platform_name:
await platform.send_by_session(session, message_chain)
return True
logger.warning(
f"cannot find platform for session {str(session)}, message not sent"
)
return False
def add_llm_tools(self, *tools: FunctionTool) -> None:
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
QQOFFICIAL = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
WECOM_AI_BOT = enum.auto()
LARK = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
@@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag):
| QQOFFICIAL
| TELEGRAM
| WECOM
| WECOM_AI_BOT
| LARK
| DINGTALK
| DISCORD
@@ -44,6 +46,7 @@ ADAPTER_NAME_2_TYPE = {
"qq_official": PlatformAdapterType.QQOFFICIAL,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
+6
View File
@@ -13,6 +13,9 @@ from .star_handler import (
register_on_llm_response,
register_on_llm_tool_respond,
register_on_platform_loaded,
register_on_plugin_error,
register_on_plugin_loaded,
register_on_plugin_unloaded,
register_on_using_llm_tool,
register_on_waiting_llm_request,
register_permission_type,
@@ -32,6 +35,9 @@ __all__ = [
"register_on_decorating_result",
"register_on_llm_request",
"register_on_llm_response",
"register_on_plugin_error",
"register_on_plugin_loaded",
"register_on_plugin_unloaded",
"register_on_platform_loaded",
"register_on_waiting_llm_request",
"register_permission_type",
+1 -1
View File
@@ -1,6 +1,6 @@
import warnings
from astrbot.core.star import StarMetadata, star_map
from astrbot.core.star.star import StarMetadata, star_map
_warned_register_star = False
+56 -5
View File
@@ -11,7 +11,6 @@ from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools
@@ -339,6 +338,58 @@ def register_on_platform_loaded(**kwargs):
return decorator
def register_on_plugin_error(**kwargs):
"""当插件处理消息异常时触发。
Hook 参数:
event, plugin_name, handler_name, error, traceback_text
说明:
hook 中调用 `event.stop_event()` 可屏蔽默认报错回显
并由插件自行决定是否转发到其他会话
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
return awaitable
return decorator
def register_on_plugin_loaded(**kwargs):
"""当有插件加载完成时
Hook 参数:
metadata
说明:
当有插件加载完成时触发该事件并获取到该插件的元数据
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs)
return awaitable
return decorator
def register_on_plugin_unloaded(**kwargs):
"""当有插件卸载完成时
Hook 参数:
metadata
说明:
当有插件卸载完成时触发该事件并获取到该插件的元数据
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs)
return awaitable
return decorator
def register_on_waiting_llm_request(**kwargs):
"""当等待调用 LLM 时的通知事件(在获取锁之前)
@@ -565,7 +616,7 @@ class RegisteringAgent:
kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
def __init__(self, agent: Agent[Any]) -> None:
self._agent = agent
@@ -573,7 +624,7 @@ def register_agent(
name: str,
instruction: str,
tools: list[str | FunctionTool] | None = None,
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
run_hooks: BaseAgentRunHooks[Any] | None = None,
):
"""注册一个 Agent
@@ -587,12 +638,12 @@ def register_agent(
tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[AstrAgentContext]
AstrAgent = Agent[Any]
agent = AstrAgent(
name=name,
instructions=instruction,
tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
)
handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable
+6
View File
@@ -61,6 +61,12 @@ class StarMetadata:
logo_path: str | None = None
"""插件 Logo 的路径"""
support_platforms: list[str] = field(default_factory=list)
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key"""
astrbot_version: str | None = None
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
+29
View File
@@ -97,6 +97,30 @@ class StarHandlerRegistry(Generic[T]):
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnPluginErrorEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnPluginLoadedEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnPluginUnloadedEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
@@ -136,6 +160,8 @@ class StarHandlerRegistry(Generic[T]):
not in (
EventType.OnAstrBotLoadedEvent,
EventType.OnPlatformLoadedEvent,
EventType.OnPluginLoadedEvent,
EventType.OnPluginUnloadedEvent,
)
and not plugin.reserved
):
@@ -192,6 +218,9 @@ class EventType(enum.Enum):
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
OnAfterMessageSentEvent = enum.auto() # 发送消息后
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
OnPluginLoadedEvent = enum.auto() # 插件加载完成
OnPluginUnloadedEvent = enum.auto() # 插件卸载完成
H = TypeVar("H", bound=Callable[..., Any])
+205 -28
View File
@@ -11,10 +11,13 @@ import traceback
from types import ModuleType
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.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
@@ -30,7 +33,7 @@ from .command_management import sync_command_configs
from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
from .star_handler import star_handlers_registry
from .star_handler import EventType, star_handlers_registry
from .updator import PluginUpdator
try:
@@ -40,12 +43,19 @@ except ImportError:
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
self.updator = PluginUpdator()
self.context = context
self.context._star_manager = self # type: ignore
StarTools.initialize(context)
self.config = config
self.plugin_store_path = get_astrbot_plugin_path()
@@ -268,10 +278,58 @@ class PluginManager:
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
support_platforms=(
[
platform_id
for platform_id in metadata["support_platforms"]
if isinstance(platform_id, str)
]
if isinstance(metadata.get("support_platforms"), list)
else []
),
astrbot_version=(
metadata["astrbot_version"]
if isinstance(metadata.get("astrbot_version"), str)
else None
),
)
return metadata
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
) -> tuple[bool, str | None]:
if not version_spec:
return True, None
normalized_spec = version_spec.strip()
if not normalized_spec:
return True, None
try:
specifier = SpecifierSet(normalized_spec)
except InvalidSpecifier:
return (
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
)
try:
current_version = Version(VERSION)
except InvalidVersion:
return (
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
if current_version not in specifier:
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
)
return True, None
@staticmethod
def _get_plugin_related_modules(
plugin_root_dir: str,
@@ -330,6 +388,33 @@ class PluginManager:
except KeyError:
logger.warning(f"模块 {module_name} 未载入")
def _cleanup_plugin_state(self, dir_name: str) -> None:
plugin_root_name = "data.plugins."
# 清理 sys.modules
for key in list(sys.modules.keys()):
if key.startswith(f"{plugin_root_name}{dir_name}"):
logger.info(f"清除了插件{dir_name}中的{key}模块")
del sys.modules[key]
possible_paths = [
f"{plugin_root_name}{dir_name}.main",
f"{plugin_root_name}{dir_name}.{dir_name}",
]
# 清理 handlers
for path in possible_paths:
handlers = star_handlers_registry.get_handlers_by_module_name(path)
for handler in handlers:
star_handlers_registry.remove(handler)
logger.info(f"清理处理器: {handler.handler_name}")
# 清理工具
for tool in list(llm_tools.func_list):
if tool.handler_module_path in possible_paths:
llm_tools.func_list.remove(tool)
logger.info(f"清理工具: {tool.name}")
async def reload_failed_plugin(self, dir_name):
"""
重新加载未注册加载失败的插件
@@ -340,17 +425,21 @@ class PluginManager:
- success (bool): 重载是否成功
- error_message (str|None): 错误信息成功时为 None
"""
async with self._pm_lock:
if dir_name in self.failed_plugin_dict:
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
if not self.failed_plugin_dict:
self.failed_plugin_info = ""
return success, None
else:
return False, error
return False, "插件不存在于失败列表中"
if dir_name not in self.failed_plugin_dict:
return False, "插件不存在于失败列表中"
self._cleanup_plugin_state(dir_name)
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
if not self.failed_plugin_dict:
self.failed_plugin_info = ""
return success, None
else:
return False, error
async def reload(self, specified_plugin_name=None):
"""重新加载插件
@@ -408,7 +497,12 @@ class PluginManager:
return result
async def load(self, specified_module_path=None, specified_dir_name=None):
async def load(
self,
specified_module_path=None,
specified_dir_name=None,
ignore_version_check: bool = False,
):
"""载入插件。
specified_module_path 或者 specified_dir_name 不为 None 只载入指定的插件
@@ -469,8 +563,19 @@ class PluginManager:
requirements_path=requirements_path,
)
except Exception as e:
logger.error(traceback.format_exc())
error_trace = traceback.format_exc()
logger.error(error_trace)
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}\n"
self.failed_plugin_dict[root_dir_name] = {
"error": str(e),
"traceback": error_trace,
}
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
continue
# 检查 _conf_schema.json
@@ -507,12 +612,37 @@ class PluginManager:
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
metadata.display_name = metadata_yaml.display_name
metadata.support_platforms = metadata_yaml.support_platforms
metadata.astrbot_version = metadata_yaml.astrbot_version
except Exception as e:
logger.warning(
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
)
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
@@ -530,17 +660,10 @@ class PluginManager:
context=self.context,
)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -618,6 +741,19 @@ class PluginManager:
)
if not metadata:
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
@@ -681,6 +817,19 @@ class PluginManager:
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
await metadata.star_cls.initialize()
# 触发插件加载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPluginLoadedEvent,
)
for handler in handlers:
try:
logger.info(
f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
)
await handler.handler(metadata)
except Exception:
logger.error(traceback.format_exc())
except BaseException as e:
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
errors = traceback.format_exc()
@@ -693,6 +842,11 @@ class PluginManager:
"traceback": errors,
}
# 记录注册失败的插件名称,以便后续重载插件
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
@@ -751,7 +905,9 @@ class PluginManager:
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)
async def install_plugin(self, repo_url: str, proxy=""):
async def install_plugin(
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
):
"""从仓库 URL 安装插件
从指定的仓库 URL 下载并安装插件然后加载该插件到系统中
@@ -785,7 +941,10 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
success, error_message = await self.load(specified_dir_name=dir_name)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message
@@ -1063,6 +1222,19 @@ class PluginManager:
elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate()
# 触发插件卸载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPluginUnloadedEvent,
)
for handler in handlers:
try:
logger.info(
f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
)
await handler.handler(star_metadata)
except Exception:
logger.error(traceback.format_exc())
async def turn_on_plugin(self, plugin_name: str) -> None:
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
@@ -1089,7 +1261,9 @@ class PluginManager:
await self.reload(plugin_name)
async def install_plugin_from_file(self, zip_file_path: str):
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
desti_dir = os.path.join(self.plugin_store_path, dir_name)
@@ -1145,7 +1319,10 @@ class PluginManager:
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
# await self.reload()
success, error_message = await self.load(specified_dir_name=dir_name)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message
+21 -1
View File
@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -8,6 +9,14 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
def _extract_job_session(job: Any) -> str | None:
payload = getattr(job, "payload", None)
if not isinstance(payload, dict):
return None
session = payload.get("session")
return str(session) if session is not None else None
@dataclass
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
name: str = "create_future_task"
@@ -119,9 +128,15 @@ class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only delete future tasks in the current umo."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
@@ -148,8 +163,13 @@ class ListCronJobsTool(FunctionTool[AstrAgentContext]):
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_type = kwargs.get("job_type")
jobs = await cron_mgr.list_jobs(job_type)
jobs = [
job
for job in await cron_mgr.list_jobs(job_type)
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
+2 -2
View File
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
@@ -0,0 +1,67 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.core.platform import AstrMessageEvent
class ActiveEventRegistry:
"""维护 unified_msg_origin 到活跃事件的映射。
用于在 reset 等场景下终止该会话正在处理的事件
"""
def __init__(self) -> None:
self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set)
def register(self, event: AstrMessageEvent) -> None:
self._events[event.unified_msg_origin].add(event)
def unregister(self, event: AstrMessageEvent) -> None:
umo = event.unified_msg_origin
self._events[umo].discard(event)
if not self._events[umo]:
del self._events[umo]
def stop_all(
self,
umo: str,
exclude: AstrMessageEvent | None = None,
) -> int:
"""终止指定 UMO 的所有活跃事件。
Args:
umo: 统一消息来源标识符
exclude: 需要排除的事件通常是发起 reset 的事件本身
Returns:
被终止的事件数量
"""
count = 0
for event in list(self._events.get(umo, [])):
if event is not exclude:
event.stop_event()
count += 1
return count
def request_agent_stop_all(
self,
umo: str,
exclude: AstrMessageEvent | None = None,
) -> int:
"""请求停止指定 UMO 的所有活跃事件中的 Agent 运行。
stop_all 不同这里不会调用 event.stop_event()
因此不会中断事件传播后续流程如历史记录保存仍可继续
"""
count = 0
for event in list(self._events.get(umo, [])):
if event is not exclude:
event.set_extra("agent_stop_requested", True)
count += 1
return count
active_event_registry = ActiveEventRegistry()
+2 -2
View File
@@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录
import os
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def get_astrbot_path() -> str:
@@ -29,7 +29,7 @@ def get_astrbot_root() -> str:
"""获取Astrbot根目录路径"""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_electron_runtime():
if is_packaged_desktop_runtime():
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
return os.path.realpath(os.getcwd())
+4 -4
View File
@@ -12,7 +12,7 @@ import threading
from collections import deque
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
@@ -35,7 +35,7 @@ def _get_pip_main():
"pip module is unavailable "
f"(sys.executable={sys.executable}, "
f"frozen={getattr(sys, 'frozen', False)}, "
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})"
) from exc
return pip_main
@@ -556,7 +556,7 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if is_packaged_electron_runtime():
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)
@@ -582,7 +582,7 @@ class PipInstaller:
def prefer_installed_dependencies(self, requirements_path: str) -> None:
"""优先使用已安装在插件 site-packages 中的依赖,不执行安装。"""
if not is_packaged_electron_runtime():
if not is_packaged_desktop_runtime():
return
target_site_packages = get_astrbot_site_packages_path()
@@ -19,7 +19,7 @@ from astrbot.core.message.components import (
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from .image_refs import looks_like_image_file_name, normalize_file_like_url
from .image_refs import looks_like_image_file_name
from .settings import SETTINGS, QuotedMessageParserSettings
_FORWARD_PLACEHOLDER_PATTERN = re.compile(
@@ -296,11 +296,11 @@ def _parse_onebot_segments(
or "file"
)
text_parts.append(f"[File:{file_name}]")
candidate_url = seg_data.get("url")
candidate_url = seg_data.get("url", "")
if (
isinstance(candidate_url, str)
and candidate_url.strip()
and looks_like_image_file_name(normalize_file_like_url(candidate_url))
and looks_like_image_file_name(candidate_url)
):
image_refs.append(candidate_url.strip())
candidate_file = seg_data.get("file")
@@ -308,11 +308,7 @@ def _parse_onebot_segments(
isinstance(candidate_file, str)
and candidate_file.strip()
and looks_like_image_file_name(
normalize_file_like_url(
seg_data.get("name")
or seg_data.get("file_name")
or candidate_file
)
seg_data.get("name") or seg_data.get("file_name") or candidate_file
)
):
image_refs.append(candidate_file.strip())
@@ -368,7 +364,9 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
if not isinstance(node, dict):
continue
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {}
sender = node.get("sender")
if not isinstance(sender, dict):
sender = {}
sender_name = (
sender.get("nickname")
or sender.get("card")
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Any
from collections.abc import Awaitable
from typing import Any, Protocol
from astrbot import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -17,6 +18,10 @@ def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]:
return ret
class CallAction(Protocol):
def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ...
class OneBotClient:
def __init__(
self,
@@ -27,7 +32,7 @@ class OneBotClient:
self._settings = settings
@staticmethod
def _resolve_call_action(event: AstrMessageEvent):
def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
bot = getattr(event, "bot", None)
api = getattr(bot, "api", None)
call_action = getattr(api, "call_action", None)
+2 -2
View File
@@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool:
return bool(getattr(sys, "frozen", False))
def is_packaged_electron_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
def is_packaged_desktop_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
+4
View File
@@ -1,3 +1,4 @@
from .api_key import ApiKeyRoute
from .auth import AuthRoute
from .backup import BackupRoute
from .chat import ChatRoute
@@ -9,6 +10,7 @@ from .cron import CronRoute
from .file import FileRoute
from .knowledge_base import KnowledgeBaseRoute
from .log import LogRoute
from .open_api import OpenApiRoute
from .persona import PersonaRoute
from .platform import PlatformRoute
from .plugin import PluginRoute
@@ -21,6 +23,7 @@ from .tools import ToolsRoute
from .update import UpdateRoute
__all__ = [
"ApiKeyRoute",
"AuthRoute",
"BackupRoute",
"ChatRoute",
@@ -32,6 +35,7 @@ __all__ = [
"FileRoute",
"KnowledgeBaseRoute",
"LogRoute",
"OpenApiRoute",
"PersonaRoute",
"PlatformRoute",
"PluginRoute",
+146
View File
@@ -0,0 +1,146 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from quart import g, request
from astrbot.core.db import BaseDatabase
from .route import Response, Route, RouteContext
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
class ApiKeyRoute(Route):
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
super().__init__(context)
self.db = db
self.routes = {
"/apikey/list": ("GET", self.list_api_keys),
"/apikey/create": ("POST", self.create_api_key),
"/apikey/revoke": ("POST", self.revoke_api_key),
"/apikey/delete": ("POST", self.delete_api_key),
}
self.register_routes()
@staticmethod
def _normalize_utc(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@classmethod
def _serialize_datetime(cls, dt: datetime | None) -> str | None:
normalized = cls._normalize_utc(dt)
if normalized is None:
return None
return normalized.astimezone().isoformat()
@staticmethod
def _hash_key(raw_key: str) -> str:
return hashlib.pbkdf2_hmac(
"sha256",
raw_key.encode("utf-8"),
b"astrbot_api_key",
100_000,
).hex()
@staticmethod
def _serialize_api_key(key) -> dict:
expires_at = ApiKeyRoute._normalize_utc(key.expires_at)
return {
"key_id": key.key_id,
"name": key.name,
"key_prefix": key.key_prefix,
"scopes": key.scopes or [],
"created_by": key.created_by,
"created_at": ApiKeyRoute._serialize_datetime(key.created_at),
"updated_at": ApiKeyRoute._serialize_datetime(key.updated_at),
"last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at),
"expires_at": ApiKeyRoute._serialize_datetime(key.expires_at),
"revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at),
"is_revoked": key.revoked_at is not None,
"is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
}
async def list_api_keys(self):
keys = await self.db.list_api_keys()
return (
Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__
)
async def create_api_key(self):
post_data = await request.json or {}
name = str(post_data.get("name", "")).strip() or "Untitled API Key"
scopes = post_data.get("scopes")
if scopes is None:
normalized_scopes = list(ALL_OPEN_API_SCOPES)
elif isinstance(scopes, list):
normalized_scopes = [
scope
for scope in scopes
if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES
]
normalized_scopes = list(dict.fromkeys(normalized_scopes))
if not normalized_scopes:
return Response().error("At least one valid scope is required").__dict__
else:
return Response().error("Invalid scopes").__dict__
expires_at = None
expires_in_days = post_data.get("expires_in_days")
if expires_in_days is not None:
try:
expires_in_days_int = int(expires_in_days)
except (TypeError, ValueError):
return Response().error("expires_in_days must be an integer").__dict__
if expires_in_days_int <= 0:
return (
Response().error("expires_in_days must be greater than 0").__dict__
)
expires_at = datetime.now(timezone.utc) + timedelta(
days=expires_in_days_int
)
raw_key = f"abk_{secrets.token_urlsafe(32)}"
key_hash = self._hash_key(raw_key)
key_prefix = raw_key[:12]
created_by = g.get("username", "unknown")
api_key = await self.db.create_api_key(
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
scopes=normalized_scopes, # type: ignore
created_by=created_by,
expires_at=expires_at,
)
payload = self._serialize_api_key(api_key)
payload["api_key"] = raw_key
return Response().ok(data=payload).__dict__
async def revoke_api_key(self):
post_data = await request.json or {}
key_id = post_data.get("key_id")
if not key_id:
return Response().error("Missing key: key_id").__dict__
success = await self.db.revoke_api_key(key_id)
if not success:
return Response().error("API key not found").__dict__
return Response().ok().__dict__
async def delete_api_key(self):
post_data = await request.json or {}
key_id = post_data.get("key_id")
if not key_id:
return Response().error("Missing key: key_id").__dict__
success = await self.db.delete_api_key(key_id)
if not success:
return Response().error("API key not found").__dict__
return Response().ok().__dict__
+5 -3
View File
@@ -64,11 +64,13 @@ class AuthRoute(Route):
new_pwd = post_data.get("new_password", None)
new_username = post_data.get("new_username", None)
if not new_pwd and not new_username:
return (
Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__
)
return Response().error("新用户名和新密码不能同时为空").__dict__
# Verify password confirmation
if new_pwd:
confirm_pwd = post_data.get("confirm_password", None)
if confirm_pwd != new_pwd:
return Response().error("两次输入的新密码不一致").__dict__
self.config["dashboard"]["password"] = new_pwd
if new_username:
self.config["dashboard"]["username"] = new_username
+78 -24
View File
@@ -13,7 +13,9 @@ from quart import g, make_response, request, send_file
from astrbot.core import logger, sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.message_type import MessageType
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.active_event_registry import active_event_registry
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .route import Response, Route, RouteContext
@@ -41,6 +43,7 @@ class ChatRoute(Route):
"/chat/new_session": ("GET", self.new_session),
"/chat/sessions": ("GET", self.get_sessions),
"/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/update_session_display_name": (
"POST",
@@ -52,8 +55,9 @@ class ChatRoute(Route):
}
self.core_lifecycle = core_lifecycle
self.register_routes()
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.imgs_dir, exist_ok=True)
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.attachments_dir, exist_ok=True)
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
@@ -69,9 +73,18 @@ class ChatRoute(Route):
return Response().error("Missing key: filename").__dict__
try:
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
real_file_path = os.path.realpath(file_path)
real_imgs_dir = os.path.realpath(self.imgs_dir)
real_imgs_dir = os.path.realpath(self.attachments_dir)
if not os.path.exists(real_file_path):
# try legacy
file_path = os.path.join(
self.legacy_img_dir, os.path.basename(filename)
)
if os.path.exists(file_path):
real_file_path = os.path.realpath(file_path)
real_imgs_dir = os.path.realpath(self.legacy_img_dir)
if not real_file_path.startswith(real_imgs_dir):
return Response().error("Invalid file path").__dict__
@@ -125,7 +138,7 @@ class ChatRoute(Route):
else:
attach_type = "file"
path = os.path.join(self.imgs_dir, filename)
path = os.path.join(self.attachments_dir, filename)
await file.save(path)
# 创建 attachment 记录
@@ -202,8 +215,13 @@ class ChatRoute(Route):
filename: 存储的文件名
attach_type: 附件类型 (image, record, file, video)
"""
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
if not os.path.exists(file_path):
basename = os.path.basename(filename)
candidate_paths = [
os.path.join(self.attachments_dir, basename),
os.path.join(self.legacy_img_dir, basename),
]
file_path = next((p for p in candidate_paths if os.path.exists(p)), None)
if not file_path:
return None
# guess mime type
@@ -317,10 +335,13 @@ class ChatRoute(Route):
)
return record
async def chat(self):
async def chat(self, post_data: dict | None = None):
username = g.get("username", "guest")
post_data = await request.json
if post_data is None:
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if "message" not in post_data and "files" not in post_data:
return Response().error("Missing key: message or files").__dict__
@@ -373,6 +394,14 @@ class ChatRoute(Route):
agent_stats = {}
refs = {}
try:
# Emit session_id first so clients can bind the stream immediately.
session_info = {
"type": "session_id",
"data": None,
"session_id": webchat_conv_id,
}
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
try:
@@ -445,13 +474,13 @@ class ChatRoute(Route):
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
accumulated_reasoning += result_text
elif streaming:
@@ -582,6 +611,36 @@ class ChatRoute(Route):
response.timeout = None # fix SSE auto disconnect issue
return response
async def stop_session(self):
"""Stop active agent runs for a session."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
session_id = post_data.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
message_type = (
MessageType.GROUP_MESSAGE.value
if session.is_group
else MessageType.FRIEND_MESSAGE.value
)
umo = (
f"{session.platform_id}:{message_type}:"
f"{session.platform_id}!{username}!{session_id}"
)
stopped_count = active_event_registry.request_agent_stop_all(umo)
return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
@@ -705,23 +764,18 @@ class ChatRoute(Route):
# 获取可选的 platform_id 参数
platform_id = request.args.get("platform_id")
sessions = await self.db.get_platform_sessions_by_creator(
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
creator=username,
platform_id=platform_id,
page=1,
page_size=100, # 暂时返回前100个
exclude_project_sessions=True,
)
# 转换为字典格式,并添加项目信息
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
# 转换为字典格式
sessions_data = []
for item in sessions:
session = item["session"]
project_id = item["project_id"]
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
if project_id is not None:
continue
sessions_data.append(
{
+17 -1
View File
@@ -754,6 +754,22 @@ class ConfigRoute(Route):
if not provider_type:
return Response().error("provider_config 缺少 type 字段").__dict__
# 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
if provider_type not in provider_cls_map:
try:
self.core_lifecycle.provider_manager.dynamic_import_provider(
provider_type,
)
except ImportError:
logger.error(traceback.format_exc())
return (
Response()
.error(
"提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
)
.__dict__
)
# 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
@@ -779,7 +795,7 @@ class ConfigRoute(Route):
if inspect.iscoroutinefunction(init_fn):
await init_fn()
# 获取嵌入向量维度
# 通过实际请求验证当前 embedding_dimensions 是否可用
vec = await inst.get_embedding("echo")
dim = len(vec)
+3 -1
View File
@@ -148,7 +148,6 @@ class ConversationRoute(Route):
user_id = data.get("user_id")
cid = data.get("cid")
title = data.get("title")
persona_id = data.get("persona_id", "")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
@@ -158,6 +157,9 @@ class ConversationRoute(Route):
)
if not conversation:
return Response().error("对话不存在").__dict__
persona_id = data.get("persona_id", conversation.persona_id)
if title is not None or persona_id is not None:
await self.conv_mgr.update_conversation(
unified_msg_origin=user_id,
+388
View File
@@ -0,0 +1,388 @@
from pathlib import Path
from uuid import uuid4
from quart import g, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSesion
from .chat import ChatRoute
from .route import Response, Route, RouteContext
class OpenApiRoute(Route):
def __init__(
self,
context: RouteContext,
db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
chat_route: ChatRoute,
) -> None:
super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
self.platform_manager = core_lifecycle.platform_manager
self.chat_route = chat_route
self.routes = {
"/v1/chat": ("POST", self.chat_send),
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
"/v1/configs": ("GET", self.get_chat_configs),
"/v1/file": ("POST", self.upload_file),
"/v1/im/message": ("POST", self.send_message),
"/v1/im/bots": ("GET", self.get_bots),
}
self.register_routes()
@staticmethod
def _resolve_open_username(
raw_username: str | None,
) -> tuple[str | None, str | None]:
if raw_username is None:
return None, "Missing key: username"
username = str(raw_username).strip()
if not username:
return None, "username is empty"
return username, None
def _get_chat_config_list(self) -> list[dict]:
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
result = []
for conf_info in conf_list:
conf_id = str(conf_info.get("id", "")).strip()
result.append(
{
"id": conf_id,
"name": str(conf_info.get("name", "")).strip(),
"path": str(conf_info.get("path", "")).strip(),
"is_default": conf_id == "default",
}
)
return result
def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
raw_config_id = post_data.get("config_id")
raw_config_name = post_data.get("config_name")
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
config_name = (
str(raw_config_name).strip() if raw_config_name is not None else ""
)
if not config_id and not config_name:
return None, None
conf_list = self._get_chat_config_list()
conf_map = {item["id"]: item for item in conf_list}
if config_id:
if config_id not in conf_map:
return None, f"config_id not found: {config_id}"
return config_id, None
if not config_name:
return None, "config_name is empty"
matched = [item for item in conf_list if item["name"] == config_name]
if not matched:
return None, f"config_name not found: {config_name}"
if len(matched) > 1:
return (
None,
f"config_name is ambiguous, please use config_id: {config_name}",
)
return matched[0]["id"], None
async def _ensure_chat_session(
self,
username: str,
session_id: str,
) -> str | None:
session = await self.db.get_platform_session_by_id(session_id)
if session:
if session.creator != username:
return "session_id belongs to another username"
return None
try:
await self.db.create_platform_session(
creator=username,
platform_id="webchat",
session_id=session_id,
is_group=0,
)
except Exception as e:
# Handle rare race when same session_id is created concurrently.
existing = await self.db.get_platform_session_by_id(session_id)
if existing and existing.creator == username:
return None
logger.error("Failed to create chat session %s: %s", session_id, e)
return f"Failed to create session: {e}"
return None
async def chat_send(self):
post_data = await request.get_json(silent=True) or {}
effective_username, username_err = self._resolve_open_username(
post_data.get("username")
)
if username_err:
return Response().error(username_err).__dict__
if not effective_username:
return Response().error("Invalid username").__dict__
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
if not session_id:
session_id = str(uuid4())
post_data["session_id"] = session_id
ensure_session_err = await self._ensure_chat_session(
effective_username,
session_id,
)
if ensure_session_err:
return Response().error(ensure_session_err).__dict__
config_id, resolve_err = self._resolve_chat_config_id(post_data)
if resolve_err:
return Response().error(resolve_err).__dict__
original_username = g.get("username", "guest")
g.username = effective_username
if config_id:
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
try:
if config_id == "default":
await self.core_lifecycle.umop_config_router.delete_route(umo)
else:
await self.core_lifecycle.umop_config_router.update_route(
umo, config_id
)
except Exception as e:
logger.error(
"Failed to update chat config route for %s with %s: %s",
umo,
config_id,
e,
exc_info=True,
)
return (
Response()
.error(f"Failed to update chat config route: {e}")
.__dict__
)
try:
return await self.chat_route.chat(post_data=post_data)
finally:
g.username = original_username
async def upload_file(self):
return await self.chat_route.post_file()
async def get_chat_sessions(self):
username, username_err = self._resolve_open_username(
request.args.get("username")
)
if username_err:
return Response().error(username_err).__dict__
assert username is not None # for type checker
try:
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
except ValueError:
return Response().error("page and page_size must be integers").__dict__
if page < 1:
page = 1
if page_size < 1:
page_size = 1
if page_size > 100:
page_size = 100
platform_id = request.args.get("platform_id")
(
paginated_sessions,
total,
) = await self.db.get_platform_sessions_by_creator_paginated(
creator=username,
platform_id=platform_id,
page=page,
page_size=page_size,
exclude_project_sessions=True,
)
sessions_data = []
for item in paginated_sessions:
session = item["session"]
sessions_data.append(
{
"session_id": session.session_id,
"platform_id": session.platform_id,
"creator": session.creator,
"display_name": session.display_name,
"is_group": session.is_group,
"created_at": session.created_at.astimezone().isoformat(),
"updated_at": session.updated_at.astimezone().isoformat(),
}
)
return (
Response()
.ok(
data={
"sessions": sessions_data,
"page": page,
"page_size": page_size,
"total": total,
}
)
.__dict__
)
async def get_chat_configs(self):
conf_list = self._get_chat_config_list()
return Response().ok(data={"configs": conf_list}).__dict__
async def _build_message_chain_from_payload(
self,
message_payload: str | list,
) -> MessageChain:
if isinstance(message_payload, str):
text = message_payload.strip()
if not text:
raise ValueError("Message is empty")
return MessageChain(chain=[Plain(text=text)])
if not isinstance(message_payload, list):
raise ValueError("message must be a string or list")
components = []
has_content = False
for part in message_payload:
if not isinstance(part, dict):
raise ValueError("message part must be an object")
part_type = str(part.get("type", "")).strip()
if part_type == "plain":
text = str(part.get("text", ""))
if text:
has_content = True
components.append(Plain(text=text))
continue
if part_type == "reply":
message_id = part.get("message_id")
if message_id is None:
raise ValueError("reply part missing message_id")
components.append(
Reply(
id=str(message_id),
message_str=str(part.get("selected_text", "")),
chain=[],
)
)
continue
if part_type not in {"image", "record", "file", "video"}:
raise ValueError(f"unsupported message part type: {part_type}")
has_content = True
file_path: Path | None = None
resolved_type = part_type
filename = str(part.get("filename", "")).strip()
attachment_id = part.get("attachment_id")
if attachment_id:
attachment = await self.db.get_attachment_by_id(str(attachment_id))
if not attachment:
raise ValueError(f"attachment not found: {attachment_id}")
file_path = Path(attachment.path)
resolved_type = attachment.type
if not filename:
filename = file_path.name
else:
raise ValueError(f"{part_type} part missing attachment_id")
if not file_path.exists():
raise ValueError(f"file not found: {file_path!s}")
file_path_str = str(file_path.resolve())
if resolved_type == "image":
components.append(Image.fromFileSystem(file_path_str))
elif resolved_type == "record":
components.append(Record.fromFileSystem(file_path_str))
elif resolved_type == "video":
components.append(Video.fromFileSystem(file_path_str))
else:
components.append(
File(name=filename or file_path.name, file=file_path_str)
)
if not components or not has_content:
raise ValueError("Message content is empty (reply only is not allowed)")
return MessageChain(chain=components)
async def send_message(self):
post_data = await request.json or {}
message_payload = post_data.get("message", {})
umo = post_data.get("umo")
if message_payload is None:
return Response().error("Missing key: message").__dict__
if not umo:
return Response().error("Missing key: umo").__dict__
try:
session = MessageSesion.from_str(str(umo))
except Exception as e:
return Response().error(f"Invalid umo: {e}").__dict__
platform_id = session.platform_name
platform_inst = next(
(
inst
for inst in self.platform_manager.platform_insts
if inst.meta().id == platform_id
),
None,
)
if not platform_inst:
return (
Response()
.error(f"Bot not found or not running for platform: {platform_id}")
.__dict__
)
try:
message_chain = await self._build_message_chain_from_payload(
message_payload
)
await platform_inst.send_by_session(session, message_chain)
return Response().ok().__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"Open API send_message failed: {e}", exc_info=True)
return Response().error(f"Failed to send message: {e}").__dict__
async def get_bots(self):
bot_ids = []
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
platform_id = platform.get("id") if isinstance(platform, dict) else None
if (
isinstance(platform_id, str)
and platform_id
and platform_id not in bot_ids
):
bot_ids.append(platform_id)
return Response().ok(data={"bot_ids": bot_ids}).__dict__
+97 -15
View File
@@ -19,8 +19,14 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType, star_handlers_registry
from astrbot.core.star.star_manager import PluginManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.star.star_manager import (
PluginManager,
PluginVersionIncompatibleError,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_temp_path,
)
from .route import Response, Route, RouteContext
@@ -46,6 +52,7 @@ class PluginRoute(Route):
super().__init__(context)
self.routes = {
"/plugin/get": ("GET", self.get_plugins),
"/plugin/check-compat": ("POST", self.check_plugin_compatibility),
"/plugin/install": ("POST", self.install_plugin),
"/plugin/install-upload": ("POST", self.install_plugin_upload),
"/plugin/update": ("POST", self.update_plugin),
@@ -73,10 +80,32 @@ class PluginRoute(Route):
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后",
EventType.OnPluginErrorEvent: "插件报错时",
}
self._logo_cache = {}
async def check_plugin_compatibility(self):
try:
data = await request.get_json()
version_spec = data.get("astrbot_version", "")
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
version_spec
)
return (
Response()
.ok(
{
"compatible": is_valid,
"message": message,
"astrbot_version": version_spec,
}
)
.__dict__
)
except Exception as e:
return Response().error(str(e)).__dict__
async def reload_failed_plugins(self):
if DEMO_MODE:
return (
@@ -117,7 +146,7 @@ class PluginRoute(Route):
try:
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
return Response().error(message).__dict__
return Response().error(message or "插件重载失败").__dict__
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
@@ -195,10 +224,11 @@ class PluginRoute(Route):
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
"""构建注册表源信息"""
data_dir = get_astrbot_data_path()
if custom_url:
# 对自定义URL生成一个安全的文件名
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
cache_file = f"data/plugins_custom_{url_hash}.json"
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
# 更安全的后缀处理方式
if custom_url.endswith(".json"):
@@ -208,7 +238,7 @@ class PluginRoute(Route):
urls = [custom_url]
else:
cache_file = "data/plugins.json"
cache_file = os.path.join(data_dir, "plugins.json")
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
urls = [
"https://api.soulter.top/astrbot/plugins",
@@ -344,6 +374,8 @@ class PluginRoute(Route):
),
"display_name": plugin.display_name,
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
}
# 检查是否为全空的幽灵插件
if not any(
@@ -438,6 +470,7 @@ class PluginRoute(Route):
post_data = await request.get_json()
repo_url = post_data["url"]
ignore_version_check = bool(post_data.get("ignore_version_check", False))
proxy: str = post_data.get("proxy", None)
if proxy:
@@ -445,10 +478,23 @@ class PluginRoute(Route):
try:
logger.info(f"正在安装插件 {repo_url}")
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
plugin_info = await self.plugin_manager.install_plugin(
repo_url,
proxy,
ignore_version_check=ignore_version_check,
)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -464,16 +510,32 @@ class PluginRoute(Route):
try:
file = await request.files
file = file["file"]
form_data = await request.form
ignore_version_check = (
str(form_data.get("ignore_version_check", "false")).lower() == "true"
)
logger.info(f"正在安装用户上传的插件 {file.filename}")
file_path = os.path.join(
get_astrbot_temp_path(),
f"plugin_upload_{file.filename}",
)
await file.save(file_path)
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
plugin_info = await self.plugin_manager.install_plugin_from_file(
file_path,
ignore_version_check=ignore_version_check,
)
# self.core_lifecycle.restart()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(plugin_info, "安装成功。").__dict__
except PluginVersionIncompatibleError as e:
return {
"status": "warning",
"message": str(e),
"data": {
"warning_type": "astrbot_version_incompatible",
"can_ignore": True,
},
}
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -636,10 +698,16 @@ class PluginRoute(Route):
logger.warning(f"插件 {plugin_name} 目录不存在")
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
plugin_dir = os.path.join(
self.plugin_manager.plugin_store_path,
plugin_obj.root_dir_name or "",
)
if plugin_obj.reserved:
plugin_dir = os.path.join(
self.plugin_manager.reserved_plugin_path,
plugin_obj.root_dir_name,
)
else:
plugin_dir = os.path.join(
self.plugin_manager.plugin_store_path,
plugin_obj.root_dir_name,
)
if not os.path.isdir(plugin_dir):
logger.warning(f"无法找到插件目录: {plugin_dir}")
@@ -673,6 +741,7 @@ class PluginRoute(Route):
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
if not plugin_name:
logger.warning("插件名称为空")
return Response().error("插件名称不能为空").__dict__
# 查找插件
@@ -683,15 +752,27 @@ class PluginRoute(Route):
break
if not plugin_obj:
logger.warning(f"插件 {plugin_name} 不存在")
return Response().error(f"插件 {plugin_name} 不存在").__dict__
if not plugin_obj.root_dir_name:
logger.warning(f"插件 {plugin_name} 目录不存在")
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
plugin_dir = os.path.join(
self.plugin_manager.plugin_store_path,
plugin_obj.root_dir_name,
)
if plugin_obj.reserved:
plugin_dir = os.path.join(
self.plugin_manager.reserved_plugin_path,
plugin_obj.root_dir_name,
)
else:
plugin_dir = os.path.join(
self.plugin_manager.plugin_store_path,
plugin_obj.root_dir_name,
)
if not os.path.isdir(plugin_dir):
logger.warning(f"无法找到插件目录: {plugin_dir}")
return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__
# 尝试多种可能的文件名
changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
@@ -711,6 +792,7 @@ class PluginRoute(Route):
return Response().error(f"读取更新日志失败: {e!s}").__dict__
# 没有找到 changelog 文件,返回 ok 但 content 为 null
logger.warning(f"插件 {plugin_name} 没有更新日志文件")
return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__
async def get_custom_source(self):
+67
View File
@@ -1,4 +1,5 @@
import asyncio
import hashlib
import logging
import os
import socket
@@ -21,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import get_local_ip_addresses
from .routes import *
from .routes.api_key import ALL_OPEN_API_SCOPES
from .routes.backup import BackupRoute
from .routes.live_chat import LiveChatRoute
from .routes.platform import PlatformRoute
@@ -53,6 +55,7 @@ class AstrBotDashboard:
) -> None:
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.db = db
# 参数指定webui目录
if webui_dir and os.path.exists(webui_dir):
@@ -88,7 +91,14 @@ class AstrBotDashboard:
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.open_api_route = OpenApiRoute(
self.context,
db,
core_lifecycle,
self.chat_route,
)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
@@ -130,6 +140,40 @@ class AstrBotDashboard:
async def auth_middleware(self):
if not request.path.startswith("/api"):
return None
if request.path.startswith("/api/v1"):
raw_key = self._extract_raw_api_key()
if not raw_key:
r = jsonify(Response().error("Missing API key").__dict__)
r.status_code = 401
return r
key_hash = hashlib.pbkdf2_hmac(
"sha256",
raw_key.encode("utf-8"),
b"astrbot_api_key",
100_000,
).hex()
api_key = await self.db.get_active_api_key_by_hash(key_hash)
if not api_key:
r = jsonify(Response().error("Invalid API key").__dict__)
r.status_code = 401
return r
if isinstance(api_key.scopes, list):
scopes = api_key.scopes
else:
scopes = list(ALL_OPEN_API_SCOPES)
required_scope = self._get_required_open_api_scope(request.path)
if required_scope and "*" not in scopes and required_scope not in scopes:
r = jsonify(Response().error("Insufficient API key scope").__dict__)
r.status_code = 403
return r
g.api_key_id = api_key.key_id
g.api_key_scopes = scopes
g.username = f"api_key:{api_key.key_id}"
await self.db.touch_api_key(api_key.key_id)
return None
allowed_endpoints = [
"/api/auth/login",
"/api/file",
@@ -158,6 +202,29 @@ class AstrBotDashboard:
r.status_code = 401
return r
@staticmethod
def _extract_raw_api_key() -> str | None:
if key := request.headers.get("X-API-Key"):
return key.strip()
auth_header = request.headers.get("Authorization", "").strip()
if auth_header.startswith("Bearer "):
return auth_header.removeprefix("Bearer ").strip()
if auth_header.startswith("ApiKey "):
return auth_header.removeprefix("ApiKey ").strip()
return None
@staticmethod
def _get_required_open_api_scope(path: str) -> str | None:
scope_map = {
"/api/v1/chat": "chat",
"/api/v1/chat/sessions": "chat",
"/api/v1/configs": "config",
"/api/v1/file": "file",
"/api/v1/im/message": "im",
"/api/v1/im/bots": "im",
}
return scope_map.get(path)
def check_port_in_use(self, port: int) -> bool:
"""跨平台检测端口是否被占用"""
try:
+4 -5
View File
@@ -1,5 +1,4 @@
import base64
import os
import traceback
from io import BytesIO
@@ -51,14 +50,14 @@ async def generate_tsne_visualization(
return None
kb = kb_helper.kb
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
index_path = kb_helper.kb_dir / "index.faiss"
# 读取 FAISS 索引
if not os.path.exists(index_path):
logger.warning(f"FAISS 索引不存在: {index_path}")
if not index_path.exists():
logger.warning(f"FAISS 索引不存在: {index_path!s}")
return None
index = faiss.read_index(index_path)
index = faiss.read_index(str(index_path))
if index.ntotal == 0:
logger.warning("索引为空")
+27
View File
@@ -0,0 +1,27 @@
## What's Changed
### 修复
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
### 优化
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
### 其他
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### Fixes
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
### Improvements
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
### Others
- Applied `ruff format` code formatting.
+32
View File
@@ -0,0 +1,32 @@
## What's Changed
### 新增
- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。
- 支持在 WebUI 搜索配置
### 修复
- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。
### 优化
- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。
- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。
- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。
- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。
- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。
## What's Changed (EN)
### New Features
- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)).
- Added an announcement section to the Welcome page, with localized announcement title support.
- Added an FAQ link to the vertical sidebar and updated navigation for localization.
### Fixes
- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)).
- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity.
### Improvements
- Enhanced the provider sources panel with a refined menu style and better mobile support.
- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)).
- Added an unsaved-changes notice on the configuration page to reduce accidental config loss.
- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)).
+37
View File
@@ -0,0 +1,37 @@
## What's Changed
### 新增
- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。
- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。
- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。
### 修复
- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。
- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。
### 优化
- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。
### 其他
- 更新 README 相关文档内容。
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### New Features
- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)).
- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)).
- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)).
- Added support for the `aihubmix` provider.
- Added LINE support notes to multilingual README files.
### Fixes
- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)).
- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)).
### Improvements
- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)).
### Others
- Updated README documentation.
- Applied `ruff format` code formatting.
+47
View File
@@ -0,0 +1,47 @@
## What's Changed
### 新增
- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。
- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。
- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。
### 修复
- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。
- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。
- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。
- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。
- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。
- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。
- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。
### 优化
- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。
### 其他
- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。
- 更新相关仓库链接与功能请求模板文案,统一中英文表达。
- 移除过时文档文件 `heihe.md`
## What's Changed (EN)
### New Features
- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)).
- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)).
- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)).
### Fixes
- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)).
- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)).
- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)).
- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)).
- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)).
- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)).
- Simplified and unified account-edit validation error responses for empty username/password scenarios.
### Improvements
- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools.
### Others
- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)).
- Updated related repository links and refined feature request template wording in both Chinese and English.
- Removed outdated documentation file `heihe.md`.
+29
View File
@@ -0,0 +1,29 @@
## What's Changed
### 新增
- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。
- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。
- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。
### 修复
- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。
- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。
### 优化
- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。
- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。
## What's Changed (EN)
### New Features
- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)).
- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)).
- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)).
### Fixes
- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)).
- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)).
### Improvements
- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)).
- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)).

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