diff --git a/astrbot-sdk/.github/instructions/INSTRUCTIONS.instructions.md b/astrbot-sdk/.github/instructions/INSTRUCTIONS.instructions.md deleted file mode 100644 index 10679f30e..000000000 --- a/astrbot-sdk/.github/instructions/INSTRUCTIONS.instructions.md +++ /dev/null @@ -1,58 +0,0 @@ -## Overview - -我正在设计一个新的架构,以实现插件与核心系统的运行时环境隔离,以换取更佳的安全性和兼容性。这个架构将会形成一个 SDK,供插件开发者使用。以下是我目前的设计思路和功能规划: - -这个 SDK 主要用于新的插件的 CLI bootstrap、Plugin Runtime 以及开发平台。 - -## 功能规划 - -### 对插件端:插件脚手架 - -1. CLI 指令: 初始化插件模版、指令等组件,作为 bootstraper - ```bash - # === Scaffold === - astr init # 新的插件模版 - astr add command # 注册一个指令 / 指令组 handler 类 - astr add listener # 注册一个监听器 - astr add llmtool # 注册一个 LLM Tool - - # 交互式创建,参考 Vue 脚手架,如: - # Is command group: [Y]es / [N]o - # Command Name: calc - # Description: xxxxxx - - # === Deployment === - astr tree # 解析 filters 已注册的 handlers,按类型列出 - astr sync # 解析 filters 已注册的 handlers,并刷写到 plugin.yaml / metadata.yaml - astr dev # 启动开发环境(WebSockets 自动连接到 AstrBot Core) - astr build # 打包并构建资产 - astr publish # 发布到 GitHub Issue / 插件市场! - ``` -2. 抽象 - 提供完整的插件开发时要用到的类和类方法的抽象 -3. 注册器 - 接受插件注册的所有 Handlers -4. 通信 - 与 AstrBot Core 通信 - -### 对核心系统端:插件运行时环境 - -- 通信 - 与插件端的双向通信 -- 插件管理 - 封装通信方法(如获取一个事件激活的 star handlers / 调用某个 Star Handler / 禁用某个插件) - -## 架构 - -我们将旧插件命名为 LegacyStar,将新插件命名为 NewStar。LegacyStar 直接运行在 AstrBot Core 进程中,而 NewStar 则运行在一个独立的进程中,通过 IPC 与 AstrBot Core 通信。NewStar 进程将使用 astrbot-sdk 作为其运行时环境。 - -对于 NewStar 与 Core 之间的通信,我们将使用 stdio 或者 WebSockets 作为 IPC 的通信通道。 - -我们会设计一个 VirtualPluginLayer,以让 Core 端可以透明地调用 NewStar 的 Handlers,就像调用 LegacyStar 一样。 - -## 通信过程 - -通信过程应该是全双工的。 - -1. Core 调用 `VirtualPluginLayer.initialize`,启动插件进程。 -2. 插件进程启动后,Core 调用 `VirtualPluginLayer.handshake()`,进行握手,获取插件的元数据,如支持的 Handlers 列表等。 -3. 当消息平台有事件(AstrMessageEvent)下发时,Core 调用 `VirtualPluginLayer.get_triggered_handlers(event)`,获取需要处理该事件的 Handlers 列表。这一步不需要通信,因为 SDK 已经在上一步缓存了插件的 Handlers 列表元数据。 -4. 如果有 handler 触发,Core 调用 `VirtualPluginLayer.call_handler(event, xxxx)`,调用某个 Handler 处理事件,等待结果返回。 -5. 4 步骤期间,handler 可能会调用一些 Core 中的方法,如发送消息、获取对话历史等,这些调用通过 RPC 方式进行。 -6. 插件 handler 处理完事件后,返回结果给 Core,Core 继续后续的事件处理流程。 -7. 插件可能会主动向 Core 发送事件通知,Core 接收到后,进行相应的处理。 diff --git a/astrbot-sdk/.github/workflows/lint.yml b/astrbot-sdk/.github/workflows/lint.yml deleted file mode 100644 index 65de60bb4..000000000 --- a/astrbot-sdk/.github/workflows/lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Code Quality Control - -on: - push: - branches: [ "main", "dev" ] - pull_request: - branches: [ "main", "dev" ] - -jobs: - lint-and-format: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install tools - run: | - pip install pyclean ruff - - - name: 1. Clean python bytecode - run: pyclean . - - - name: 2. Ruff format - run: ruff format . - - - name: 3. Ruff check and fix - run: ruff check . --fix - continue-on-error: true - env: - PYTHONIOENCODING: utf-8 diff --git a/astrbot-sdk/.gitignore b/astrbot-sdk/.gitignore deleted file mode 100644 index 1a0c1502b..000000000 --- a/astrbot-sdk/.gitignore +++ /dev/null @@ -1,60 +0,0 @@ -# OS files -.DS_Store - -# Python bytecode and caches -__pycache__/ -*.py[cod] -*.pyd -*.so -.pytest_cache/ -pytest-cache-files-*/ -.mypy_cache/ -.ruff_cache/ -.coverage -.coverage.* -htmlcov/ - -# Build artifacts -build/ -dist/ -site/ -wheels/ -*.egg-info/ -.eggs/ -pip-wheel-metadata/ - -# -fork-docs/ -tmp/ -openspec/ -scripts/ -cs/ -test_plugin/astrbot_plugin_interface_coverage -docs/ -astrbot_sdk/ -!src/astrbot_sdk/ -!src/astrbot_sdk/** -src/astrbot_sdk/**/__pycache__/ -src/astrbot_sdk/**/*.py[cod] -COMMAND_MATCH_REFACTOR_REPORT.md - -# Virtual environments -.venv/ -venv/ -env/ -ENV/ -plugins/.venv/ - -# Tool caches -.uv-cache/ -.astrbot/ -.codex-local/ - -# IDE files -.idea/ -.vscode/ -*.iml -uv.lock -/astrBot/ -plugins/ -.serena/ diff --git a/astrbot-sdk/.python-version b/astrbot-sdk/.python-version deleted file mode 100644 index e4fba2183..000000000 --- a/astrbot-sdk/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/astrbot-sdk/AGENTS.md b/astrbot-sdk/AGENTS.md deleted file mode 100644 index 3de989e63..000000000 --- a/astrbot-sdk/AGENTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# Notes - -## v4 架构约束 - -### 运行时层 - -- `Peer` 必须将 transport EOF/连接断开视为一级失败路径。如果 transport 意外关闭而 `Peer` 没有主动失败 `_pending_results` / `_pending_streams`,supervisor 端对 worker 的调用可能永远挂起。 -- `Peer.initialize()` 需要在发起端也标记远程已初始化。仅在被动接收 `InitializeMessage` 时设置 `_remote_initialized` 会导致 `wait_until_remote_initialized()` 单边 API 死锁。 -- `Peer.invoke_stream()` 默认隐藏 `completed` 事件。需要保留最终结果的调用者必须显式启用 `include_completed=True`。 -- `CapabilityRouter.register(..., stream_handler=...)` 使用 `(request_id, payload, cancel_token)` 签名,不是 peer 级别的 `(message, token)`。 - -### 模块导出约束 - -- 保持 `astrbot_sdk.runtime` 根导出狭窄。`Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` 是合理的高级运行时原语,但 `LoadedPlugin`、`PluginEnvironmentManager`、`WorkerSession`、`run_supervisor` 等应留在子模块中。 - -### 测试与 Mock 注意事项 - -- 当检查 peer 是否完成远程初始化时,避免对可能接收 `MagicMock` peer 的代码使用 `getattr(mock, "remote_peer")` 探测。`MagicMock` 会生成 truthy 子属性,`CapabilityProxy` 应从 `peer.__dict__` 或其他具体存储位置读取显式状态。 -- `test_plugin/old/` 和 `test_plugin/new/` 可能包含已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件。 - -### 插件加载注意事项 - -- 本地 `dev --watch` 或同一路径插件重复加载场景,不能只依赖 `import_string()` 的跨插件模块根冲突清理。热重载前必须按插件目录清理模块缓存。 -- `_prepare_plugin_import()` 不能只在插件目录"不在 `sys.path`"时才插入路径。像 `main.py` 这种通用模块名,如果插件目录已在 `sys.path` 但排在后面,`import main` 仍会先命中别处模块;导入前必须把目标插件目录提到 `sys.path[0]`。 -- 示例/夹具测试如果直接用裸模块名导入插件入口(例如 `from main import HelloPlugin`),会污染 `sys.modules["main"]`,随后真实 loader 再按 `main:HelloPlugin` 加载时可能串到错误模块。 - ---- - -# 开发命令 - -## 格式化与检查 - -在提交代码前,请依次运行以下命令: - -```bash -ruff format . # 使用 ruff 格式化全局代码 -ruff check . --fix # 使用 ruff 检查并自动修复全局格式问题 -``` - -## 测试 - -如果修改了内容可能影响现有功能,请运行测试以确保没有引入错误: -如果修改了bug或者更改了功能需要添加新的测试 -当前仓库已统一使用 `tests/` 目录,`tests_v4/` 不再作为新增测试入口。 -仓库当前没有 `run_tests.py`,请直接使用 `pytest`。 - -```bash -python -m pytest tests -q # 运行 tests 目录全部测试 -python -m pytest tests -v # 详细输出 -python -m pytest tests -k "test_context_register_task" # 运行匹配模式的测试 -python -m pytest tests --cov=astrbot_sdk # 运行测试并生成覆盖率报告 -``` - -## 设计原则 - -新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 -不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。 diff --git a/astrbot-sdk/CLAUDE.md b/astrbot-sdk/CLAUDE.md deleted file mode 100644 index 634d97d00..000000000 --- a/astrbot-sdk/CLAUDE.md +++ /dev/null @@ -1,57 +0,0 @@ -# CLAUDE Notes - -## v4 架构约束 - -### 运行时层 - -- `Peer` 必须将 transport EOF/连接断开视为一级失败路径。如果 transport 意外关闭而 `Peer` 没有主动失败 `_pending_results` / `_pending_streams`,supervisor 端对 worker 的调用可能永远挂起。 -- `Peer.initialize()` 需要在发起端也标记远程已初始化。仅在被动接收 `InitializeMessage` 时设置 `_remote_initialized` 会导致 `wait_until_remote_initialized()` 单边 API 死锁。 -- `Peer.invoke_stream()` 默认隐藏 `completed` 事件。需要保留最终结果的调用者必须显式启用 `include_completed=True`。 -- `CapabilityRouter.register(..., stream_handler=...)` 使用 `(request_id, payload, cancel_token)` 签名,不是 peer 级别的 `(message, token)`。 - -### 模块导出约束 - -- 保持 `astrbot_sdk.runtime` 根导出狭窄。`Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` 是合理的高级运行时原语,但 `LoadedPlugin`、`PluginEnvironmentManager`、`WorkerSession`、`run_supervisor` 等应留在子模块中。 - -### 测试与 Mock 注意事项 - -- 当检查 peer 是否完成远程初始化时,避免对可能接收 `MagicMock` peer 的代码使用 `getattr(mock, "remote_peer")` 探测。`MagicMock` 会生成 truthy 子属性,`CapabilityProxy` 应从 `peer.__dict__` 或其他具体存储位置读取显式状态。 -- `test_plugin/old/` 和 `test_plugin/new/` 可能包含已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件。 - -### 插件加载注意事项 - -- 本地 `dev --watch` 或同一路径插件重复加载场景,不能只依赖 `import_string()` 的跨插件模块根冲突清理。热重载前必须按插件目录清理模块缓存。 -- `_prepare_plugin_import()` 不能只在插件目录"不在 `sys.path`"时才插入路径。像 `main.py` 这种通用模块名,如果插件目录已在 `sys.path` 但排在后面,`import main` 仍会先命中别处模块;导入前必须把目标插件目录提到 `sys.path[0]`。 -- 示例/夹具测试如果直接用裸模块名导入插件入口(例如 `from main import HelloPlugin`),会污染 `sys.modules["main"]`,随后真实 loader 再按 `main:HelloPlugin` 加载时可能串到错误模块。 - ---- - -# 开发命令 - -## 格式化与检查 - -在提交代码前,请依次运行以下命令: - -```bash -ruff format . # 使用 ruff 格式化全局代码 -ruff check . --fix # 使用 ruff 检查并自动修复全局格式问题 -``` - -## 测试 - -如果修改了内容可能影响现有功能,请运行测试以确保没有引入错误: -如果修改了bug或者更改了功能需要添加新的测试 -当前仓库已统一使用 `tests/` 目录,`tests_v4/` 不再作为新增测试入口。 -仓库当前没有 `run_tests.py`,请直接使用 `pytest`。 - -```bash -python -m pytest tests -q # 运行 tests 目录全部测试 -python -m pytest tests -v # 详细输出 -python -m pytest tests -k "test_context_register_task" # 运行匹配模式的测试 -python -m pytest tests --cov=astrbot_sdk # 运行测试并生成覆盖率报告 -``` - -## 设计原则 - -新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践 -不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。 diff --git a/astrbot-sdk/README.md b/astrbot-sdk/README.md deleted file mode 100644 index 1b6a32fc5..000000000 --- a/astrbot-sdk/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# AstrBot SDK - -AstrBot 插件开发 SDK,提供 v4 runtime、worker protocol 和插件工具链。 - -## 安装 - -```bash -pip install astrbot-sdk -``` - -## 开发安装 - -```bash -# 克隆仓库后 -pip install -e . - -# 或使用 uv -uv sync -``` - -## 目录结构 - -``` -astrbot-sdk/ -├── src/ -│ └── astrbot_sdk/ # SDK 主包 -├── pyproject.toml -└── README.md -``` diff --git a/astrbot-sdk/pyproject.toml b/astrbot-sdk/pyproject.toml deleted file mode 100644 index f90d4aed3..000000000 --- a/astrbot-sdk/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[build-system] -requires = ["setuptools>=80", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "astrbot-sdk" -version = "0.1.0" -description = "AstrBot SDK with v4 runtime, worker protocol, and plugin tooling" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "aiohttp>=3.13.2", - "anthropic>=0.72.1", - "certifi>=2025.10.5", - "click>=8.3.0", - "docstring-parser>=0.17.0", - "google-genai>=1.50.0", - "loguru>=0.7.3", - "msgpack>=1.1.1", - "openai>=2.7.2", - "pydantic>=2.12.3", - "pyyaml>=6.0.3", - "uv>=0.9.17", -] - -[project.scripts] -astr = "astrbot_sdk.cli:cli" -astrbot-sdk = "astrbot_sdk.cli:cli" - -[tool.pytest.ini_options] -markers = [ - "unit: unit tests", -] - -# ============================================================ -# Package Discovery (src layout) -# ============================================================ -[tool.setuptools.packages.find] -where = ["src"] - -# ============================================================ -# Optional Dependencies -# ============================================================ -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.24.0", - "pytest-cov>=5.0.0", - "ruff>=0.4.0", -] diff --git a/astrbot-sdk/src/astrbot_sdk/AGENTS.md b/astrbot-sdk/src/astrbot_sdk/AGENTS.md deleted file mode 100644 index 40b2a8f93..000000000 --- a/astrbot-sdk/src/astrbot_sdk/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# Notes - -## v4 架构约束 - -### 运行时层 - -- `Peer` 必须将 transport EOF/连接断开视为一级失败路径。如果 transport 意外关闭而 `Peer` 没有主动失败 `_pending_results` / `_pending_streams`,supervisor 端对 worker 的调用可能永远挂起。 -- `Peer.initialize()` 需要在发起端也标记远程已初始化。仅在被动接收 `InitializeMessage` 时设置 `_remote_initialized` 会导致 `wait_until_remote_initialized()` 单边 API 死锁。 -- `Peer.invoke_stream()` 默认隐藏 `completed` 事件。需要保留最终结果的调用者必须显式启用 `include_completed=True`。 -- `CapabilityRouter.register(..., stream_handler=...)` 使用 `(request_id, payload, cancel_token)` 签名,不是 peer 级别的 `(message, token)`。 - -### 模块导出约束 - -- 保持 `astrbot_sdk.runtime` 根导出狭窄。`Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` 是合理的高级运行时原语,但 `LoadedPlugin`、`PluginEnvironmentManager`、`WorkerSession`、`run_supervisor` 等应留在子模块中。 - -### 测试与 Mock 注意事项 - -- 当检查 peer 是否完成远程初始化时,避免对可能接收 `MagicMock` peer 的代码使用 `getattr(mock, "remote_peer")` 探测。`MagicMock` 会生成 truthy 子属性,`CapabilityProxy` 应从 `peer.__dict__` 或其他具体存储位置读取显式状态。 -- `test_plugin/old/` 和 `test_plugin/new/` 可能包含已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件。 - -### 插件加载注意事项 - -- 本地 `dev --watch` 或同一路径插件重复加载场景,不能只依赖 `import_string()` 的跨插件模块根冲突清理。热重载前必须按插件目录清理模块缓存。 -- `_prepare_plugin_import()` 不能只在插件目录"不在 `sys.path`"时才插入路径。像 `main.py` 这种通用模块名,如果插件目录已在 `sys.path` 但排在后面,`import main` 仍会先命中别处模块;导入前必须把目标插件目录提到 `sys.path[0]`。 -- 示例/夹具测试如果直接用裸模块名导入插件入口(例如 `from main import HelloPlugin`),会污染 `sys.modules["main"]`,随后真实 loader 再按 `main:HelloPlugin` 加载时可能串到错误模块。 - ---- - -# 开发命令 - -## 格式化与检查 - -在提交代码前,请依次运行以下命令: - -```bash -ruff format . # 使用 ruff 格式化全局代码 -ruff check . --fix # 使用 ruff 检查并自动修复全局格式问题 -``` - -## 设计原则 - -新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践,这是第一原则 -不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。 diff --git a/astrbot-sdk/src/astrbot_sdk/__init__.py b/astrbot-sdk/src/astrbot_sdk/__init__.py deleted file mode 100644 index 161513cd5..000000000 --- a/astrbot-sdk/src/astrbot_sdk/__init__.py +++ /dev/null @@ -1,200 +0,0 @@ -"""AstrBot SDK 的顶层公共 API。 - -这里仅重新导出 v4 推荐直接导入的稳定入口。 - -新插件应直接使用此模块的导出: - from astrbot_sdk import Star, Context, MessageEvent - from astrbot_sdk.decorators import on_command, on_message - -迁移期适配入口位于独立模块;此处只暴露 v4 原生主入口。 -""" - -from .clients.managers import ( - ConversationCreateParams, - ConversationManagerClient, - ConversationRecord, - ConversationUpdateParams, - KnowledgeBaseCreateParams, - KnowledgeBaseDocumentRecord, - KnowledgeBaseDocumentUploadParams, - KnowledgeBaseManagerClient, - KnowledgeBaseRecord, - KnowledgeBaseRetrieveResult, - KnowledgeBaseRetrieveResultItem, - KnowledgeBaseUpdateParams, - MessageHistoryManagerClient, - MessageHistoryPage, - MessageHistoryRecord, - MessageHistorySender, - PersonaCreateParams, - PersonaManagerClient, - PersonaRecord, - PersonaUpdateParams, -) -from .clients.mcp import MCPManagerClient, MCPServerRecord, MCPServerScope, MCPSession -from .clients.metadata import PluginMetadata, StarMetadata -from .clients.platform import PlatformError, PlatformStats, PlatformStatus -from .clients.provider import ( - ManagedProviderRecord, - ProviderChangeEvent, - ProviderManagerClient, -) -from .clients.session import SessionPluginManager, SessionServiceManager -from .commands import CommandGroup, command_group, print_cmd_tree -from .context import Context -from .conversation import ( - ConversationClosed, - ConversationReplaced, - ConversationSession, - ConversationState, -) -from .decorators import ( - acknowledge_global_mcp_risk, - admin_only, - conversation_command, - cooldown, - group_only, - message_types, - on_command, - on_event, - on_message, - on_schedule, - platforms, - priority, - private_only, - provide_capability, - rate_limit, - require_admin, -) -from .errors import AstrBotError -from .events import MessageEvent -from .filters import ( - CustomFilter, - MessageTypeFilter, - PlatformFilter, - all_of, - any_of, - custom_filter, -) -from .message.components import ( - At, - AtAll, - BaseMessageComponent, - File, - Forward, - Image, - MediaHelper, - Plain, - Poke, - Record, - Reply, - UnknownComponent, - Video, -) -from .message.result import ( - EventResultType, - MessageBuilder, - MessageChain, - MessageEventResult, -) -from .message.session import MessageSession -from .plugin_kv import PluginKVStoreMixin -from .schedule import ScheduleContext -from .session_waiter import SessionController, session_waiter -from .star import Star -from .star_tools import StarTools -from .types import GreedyStr - -__all__ = [ - "AstrBotError", - "At", - "AtAll", - "BaseMessageComponent", - "CommandGroup", - "ConversationClosed", - "ConversationCreateParams", - "ConversationManagerClient", - "ConversationReplaced", - "ConversationRecord", - "ConversationSession", - "ConversationState", - "ConversationUpdateParams", - "Context", - "CustomFilter", - "EventResultType", - "File", - "Forward", - "GreedyStr", - "Image", - "KnowledgeBaseCreateParams", - "KnowledgeBaseDocumentRecord", - "KnowledgeBaseDocumentUploadParams", - "KnowledgeBaseManagerClient", - "KnowledgeBaseRecord", - "KnowledgeBaseRetrieveResult", - "KnowledgeBaseRetrieveResultItem", - "KnowledgeBaseUpdateParams", - "ManagedProviderRecord", - "MCPManagerClient", - "MCPSession", - "MCPServerRecord", - "MCPServerScope", - "MediaHelper", - "MessageHistoryManagerClient", - "MessageHistoryPage", - "MessageHistoryRecord", - "MessageHistorySender", - "MessageEvent", - "MessageEventResult", - "MessageChain", - "MessageBuilder", - "MessageSession", - "MessageTypeFilter", - "Plain", - "PluginKVStoreMixin", - "PluginMetadata", - "PlatformFilter", - "PlatformError", - "PlatformStats", - "PlatformStatus", - "Poke", - "PersonaCreateParams", - "PersonaManagerClient", - "PersonaRecord", - "PersonaUpdateParams", - "ProviderChangeEvent", - "ProviderManagerClient", - "Record", - "Reply", - "ScheduleContext", - "SessionPluginManager", - "SessionServiceManager", - "SessionController", - "Star", - "StarMetadata", - "StarTools", - "UnknownComponent", - "Video", - "acknowledge_global_mcp_risk", - "admin_only", - "all_of", - "any_of", - "cooldown", - "conversation_command", - "command_group", - "custom_filter", - "group_only", - "message_types", - "on_command", - "on_event", - "on_message", - "on_schedule", - "platforms", - "print_cmd_tree", - "priority", - "provide_capability", - "private_only", - "rate_limit", - "require_admin", - "session_waiter", -] diff --git a/astrbot-sdk/src/astrbot_sdk/__main__.py b/astrbot-sdk/src/astrbot_sdk/__main__.py deleted file mode 100644 index 624fd22f4..000000000 --- a/astrbot-sdk/src/astrbot_sdk/__main__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""`python -m astrbot_sdk` 的 CLI 入口。""" - -from .cli import cli - - -def main() -> None: - cli() - - -if __name__ == "__main__": - main() diff --git a/astrbot-sdk/src/astrbot_sdk/_command_model.py b/astrbot-sdk/src/astrbot_sdk/_command_model.py deleted file mode 100644 index fd8f1ad85..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_command_model.py +++ /dev/null @@ -1,17 +0,0 @@ -from ._internal.command_model import ( - COMMAND_MODEL_DOCS_URL, - CommandModelParseResult, - ResolvedCommandModelParam, - format_command_model_help, - parse_command_model_remainder, - resolve_command_model_param, -) - -__all__ = [ - "COMMAND_MODEL_DOCS_URL", - "CommandModelParseResult", - "ResolvedCommandModelParam", - "format_command_model_help", - "parse_command_model_remainder", - "resolve_command_model_param", -] diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/__init__.py b/astrbot-sdk/src/astrbot_sdk/_internal/__init__.py deleted file mode 100644 index 6ccc0d22e..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Internal implementation modules for astrbot_sdk. - -This package groups private helpers that are not part of the public SDK API. -Imports outside the SDK should avoid depending on these modules directly. -""" - -__all__: list[str] = [] diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/command_model.py b/astrbot-sdk/src/astrbot_sdk/_internal/command_model.py deleted file mode 100644 index b0029697e..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/command_model.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -import inspect -from dataclasses import dataclass -from typing import Any - -from pydantic import BaseModel - -from ..errors import AstrBotError -from ..runtime._command_matching import split_command_remainder -from .injected_params import is_framework_injected_parameter -from .typing_utils import unwrap_optional - -# TODO:文档内容喵 -COMMAND_MODEL_DOCS_URL = "https://docs.astrbot.org/sdk/parameter-injection" - - -@dataclass(slots=True) -class ResolvedCommandModelParam: - name: str - model_cls: type[BaseModel] - - -@dataclass(slots=True) -class CommandModelParseResult: - model: BaseModel | None = None - help_text: str | None = None - - -def resolve_command_model_param(handler: Any) -> ResolvedCommandModelParam | None: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return None - try: - type_hints = inspect.get_annotations(handler, eval_str=True) - except Exception: - type_hints = {} - - candidates: list[ResolvedCommandModelParam] = [] - other_names: list[str] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - annotation = type_hints.get(parameter.name) - if _is_injected_parameter(parameter.name, annotation): - continue - normalized, _is_optional = unwrap_optional(annotation) - if isinstance(normalized, type) and issubclass(normalized, BaseModel): - candidates.append( - ResolvedCommandModelParam( - name=parameter.name, - model_cls=normalized, - ) - ) - continue - other_names.append(parameter.name) - - if not candidates: - return None - if len(candidates) > 1 or other_names: - names = [item.name for item in candidates] - raise ValueError( - "Command BaseModel injection requires exactly one non-injected BaseModel " - f"parameter, got models={names!r} others={other_names!r}" - ) - _validate_supported_model(candidates[0].model_cls) - return candidates[0] - - -def parse_command_model_remainder( - *, - remainder: str, - model_param: ResolvedCommandModelParam, - command_name: str, -) -> CommandModelParseResult: - tokens = split_command_remainder(remainder) - if any(token in {"-h", "--help"} for token in tokens): - return CommandModelParseResult( - help_text=format_command_model_help(command_name, model_param.model_cls) - ) - - fields = model_param.model_cls.model_fields - explicit_values: dict[str, Any] = {} - positional_values: dict[str, Any] = {} - positional_field_names = [ - name - for name, field in fields.items() - if _supported_scalar_type(field.annotation)[0] is not bool - ] - positional_index = 0 - index = 0 - while index < len(tokens): - token = tokens[index] - if not token.startswith("--"): - assigned = False - while positional_index < len(positional_field_names): - field_name = positional_field_names[positional_index] - positional_index += 1 - if field_name in explicit_values or field_name in positional_values: - continue - positional_values[field_name] = token - assigned = True - break - if not assigned: - raise _command_parse_error("Too many positional arguments") - index += 1 - continue - - raw_name = token[2:] - if not raw_name: - raise _command_parse_error("Invalid option '--'") - explicit_value: str | None = None - if "=" in raw_name: - raw_name, explicit_value = raw_name.split("=", 1) - negated = raw_name.startswith("no-") - # 与 argparse/click 惯例一致:--foo-bar 自动映射为字段名 foo_bar - field_name = (raw_name[3:] if negated else raw_name).replace("-", "_") - field = fields.get(field_name) - if field is None: - raise _command_parse_error(f"Unknown field: {field_name}") - if field_name in explicit_values: - raise _command_parse_error(f"Duplicate field: {field_name}") - field_type, _is_optional = _supported_scalar_type(field.annotation) - if field_type is bool: - if explicit_value is not None: - raise _command_parse_error( - f"Boolean field '{field_name}' only supports --{field_name} or --no-{field_name}" - ) - explicit_values[field_name] = not negated - index += 1 - continue - if negated: - raise _command_parse_error( - f"Non-boolean field '{field_name}' does not support --no-{field_name}" - ) - if explicit_value is None: - index += 1 - if index >= len(tokens): - raise _command_parse_error(f"Missing value for field: {field_name}") - explicit_value = tokens[index] - explicit_values[field_name] = explicit_value - index += 1 - - values = {**positional_values, **explicit_values} - - try: - model = model_param.model_cls.model_validate(values) - except Exception as exc: - raise AstrBotError.invalid_input( - "命令参数解析失败", - hint=str(exc), - docs_url=COMMAND_MODEL_DOCS_URL, - details={ - "command": command_name, - "parameter": model_param.name, - "values": values, - }, - ) from exc - return CommandModelParseResult(model=model) - - -def format_command_model_help(command_name: str, model_cls: type[BaseModel]) -> str: - _validate_supported_model(model_cls) - lines = [f"用法: /{command_name} [options]"] - if model_cls.model_fields: - lines.append("参数:") - for name, field in model_cls.model_fields.items(): - field_type, is_optional = _supported_scalar_type(field.annotation) - type_name = getattr(field_type, "__name__", str(field_type)) - required = field.is_required() - default_text = "" - if not required: - default_text = f",默认 {field.default!r}" - elif is_optional: - default_text = ",默认 None" - description = str(field.description or "").strip() - detail = f"{name}: {type_name}" - if description: - detail += f" - {description}" - detail += ",必填" if required else ",可选" - detail += default_text - if field_type is bool: - detail += f",使用 --{name} / --no-{name}" - lines.append(detail) - return "\n".join(lines) - - -def _validate_supported_model(model_cls: type[BaseModel]) -> None: - for name, field in model_cls.model_fields.items(): - try: - _supported_scalar_type(field.annotation) - except TypeError as exc: - raise ValueError( - f"Unsupported command model field '{name}': {exc}" - ) from exc - - -def _supported_scalar_type(annotation: Any) -> tuple[type[Any], bool]: - normalized, is_optional = unwrap_optional(annotation) - if normalized in {str, int, float, bool}: - return normalized, is_optional - raise TypeError("only str/int/float/bool and Optional variants are supported") - - -def _command_parse_error(message: str) -> AstrBotError: - return AstrBotError.invalid_input( - message, - docs_url=COMMAND_MODEL_DOCS_URL, - ) - - -def _is_injected_parameter(name: str, annotation: Any) -> bool: - return is_framework_injected_parameter(name, annotation) - - -__all__ = [ - "COMMAND_MODEL_DOCS_URL", - "CommandModelParseResult", - "ResolvedCommandModelParam", - "format_command_model_help", - "parse_command_model_remainder", - "resolve_command_model_param", -] diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/injected_params.py b/astrbot-sdk/src/astrbot_sdk/_internal/injected_params.py deleted file mode 100644 index ced6229f9..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/injected_params.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import functools -import inspect -from typing import Any - -try: - from typing import get_type_hints -except ImportError: # pragma: no cover - get_type_hints = None - -from .typing_utils import unwrap_optional - -_INJECTED_PARAMETER_NAMES = { - "event", - "ctx", - "context", - "sched", - "schedule", - "conversation", - "conv", -} - - -def is_framework_injected_parameter(name: str, annotation: Any) -> bool: - if name in _INJECTED_PARAMETER_NAMES: - return True - normalized, _is_optional = unwrap_optional(annotation) - if normalized is None: - return False - try: - injected_types = _framework_injected_types() - except Exception: - return False - if normalized in injected_types: - return True - if isinstance(normalized, type): - return issubclass(normalized, injected_types) - return False - - -def legacy_arg_parameter_names(handler: Any) -> list[str]: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return [] - try: - if get_type_hints is None: - type_hints = {} - else: - type_hints = get_type_hints(handler) - except Exception: - type_hints = {} - - names: list[str] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - if is_framework_injected_parameter( - parameter.name, type_hints.get(parameter.name) - ): - continue - names.append(parameter.name) - return names - - -@functools.lru_cache(maxsize=1) -def _framework_injected_types() -> tuple[type[Any], ...]: - from ..clients.llm import LLMResponse - from ..context import Context - from ..conversation import ConversationSession - from ..events import MessageEvent - from ..llm.entities import ProviderRequest - from ..message.result import MessageEventResult - from ..schedule import ScheduleContext - - return ( - Context, - MessageEvent, - ScheduleContext, - ConversationSession, - ProviderRequest, - LLMResponse, - MessageEventResult, - ) - - -__all__ = ["is_framework_injected_parameter", "legacy_arg_parameter_names"] diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/invocation_context.py b/astrbot-sdk/src/astrbot_sdk/_internal/invocation_context.py deleted file mode 100644 index 2fe2ec1d5..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/invocation_context.py +++ /dev/null @@ -1,86 +0,0 @@ -"""插件调用者身份上下文管理。 - -本模块使用 contextvars 实现跨异步任务传播插件身份, -用于在 capability 调用时自动识别调用者插件。 - -典型场景: - - http.register_api: 记录哪个插件注册了 API - - metadata.get_plugin_config: 只允许查询当前插件自己的配置 - - 能力路由层权限校验 - -使用方式: - with caller_plugin_scope("my_plugin"): - # 在此作用域内,current_caller_plugin_id() 返回 "my_plugin" - await ctx.http.register_api(...) - -注意: - contextvars 会自动传播到子任务(asyncio.create_task), - 无需手动传递。 -""" - -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from contextvars import ContextVar, Token - -# 存储当前调用者插件 ID 的上下文变量 -_CALLER_PLUGIN_ID: ContextVar[str | None] = ContextVar( - "astrbot_sdk_caller_plugin_id", - default=None, -) - - -def current_caller_plugin_id() -> str | None: - """获取当前上下文中的调用者插件 ID。 - - Returns: - 当前插件 ID,如果不在插件调用上下文中则返回 None - """ - return _CALLER_PLUGIN_ID.get() - - -def bind_caller_plugin_id(plugin_id: str | None) -> Token[str | None]: - """绑定调用者插件 ID 到当前上下文。 - - Args: - plugin_id: 插件 ID,空字符串会被视为 None - - Returns: - 用于后续 reset 的 Token - - Note: - 通常使用 caller_plugin_scope 上下文管理器而非直接调用此函数 - """ - normalized = plugin_id.strip() if isinstance(plugin_id, str) else "" - return _CALLER_PLUGIN_ID.set(normalized or None) - - -def reset_caller_plugin_id(token: Token[str | None]) -> None: - """重置调用者插件 ID 到之前的状态。 - - Args: - token: bind_caller_plugin_id 返回的 Token - """ - _CALLER_PLUGIN_ID.reset(token) - - -@contextmanager -def caller_plugin_scope(plugin_id: str | None) -> Iterator[None]: - """创建一个绑定插件身份的上下文作用域。 - - Args: - plugin_id: 要绑定的插件 ID - - Yields: - None - - 示例: - with caller_plugin_scope("my_plugin"): - await some_capability_call() - """ - token = bind_caller_plugin_id(plugin_id) - try: - yield - finally: - reset_caller_plugin_id(token) diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/memory_utils.py b/astrbot-sdk/src/astrbot_sdk/_internal/memory_utils.py deleted file mode 100644 index d13720b50..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/memory_utils.py +++ /dev/null @@ -1,213 +0,0 @@ -from __future__ import annotations - -import json -import math -import re -from datetime import datetime, timedelta, timezone -from typing import Any - - -def is_ttl_memory_entry(value: Any) -> bool: - """Return whether a stored memory payload uses the TTL wrapper shape.""" - - return isinstance(value, dict) and "value" in value and "ttl_seconds" in value - - -def memory_value_for_search(stored: Any) -> dict[str, Any] | None: - """Unwrap the search payload from a stored memory record when possible.""" - - if not isinstance(stored, dict): - return None - if is_ttl_memory_entry(stored): - value = stored.get("value") - return value if isinstance(value, dict) else None - return stored - - -def extract_memory_text(stored: Any) -> str: - """Pick the canonical text that keyword/vector search should index.""" - - value = memory_value_for_search(stored) - if not isinstance(value, dict): - return "" - for field_name in ("embedding_text", "content", "summary", "title", "text"): - item = value.get(field_name) - if isinstance(item, str) and item.strip(): - return item.strip() - return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) - - -def memory_expiration_from_ttl(ttl_seconds: Any) -> datetime | None: - """Translate a TTL in seconds into an absolute UTC expiration timestamp.""" - - try: - ttl = int(ttl_seconds) - except (TypeError, ValueError): - return None - if ttl < 1: - return None - return datetime.now(timezone.utc) + timedelta(seconds=ttl) - - -def memory_expiration_from_stored_payload(stored: Any) -> datetime | None: - """Recover an absolute expiration timestamp from a stored TTL payload.""" - - if not is_ttl_memory_entry(stored) or not isinstance(stored, dict): - return None - raw_expires_at = stored.get("expires_at") - if isinstance(raw_expires_at, (int, float)): - return datetime.fromtimestamp(float(raw_expires_at), tz=timezone.utc) - if not isinstance(raw_expires_at, str): - return None - - normalized = raw_expires_at.strip() - if not normalized: - return None - if normalized.endswith("Z"): - normalized = f"{normalized[:-1]}+00:00" - try: - expires_at = datetime.fromisoformat(normalized) - except ValueError: - return None - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - return expires_at.astimezone(timezone.utc) - - -def normalize_memory_namespace(value: Any) -> str: - """Normalize a namespace path into a stable slash-delimited string.""" - - if value is None: - return "" - if isinstance(value, (list, tuple)): - return join_memory_namespace(*value) - text = str(value).strip().replace("\\", "/") - if not text: - return "" - parts = [segment.strip() for segment in text.split("/") if segment.strip()] - return "/".join(parts) - - -def join_memory_namespace(*parts: Any) -> str: - """Join namespace segments while preserving the root namespace as empty.""" - - normalized_parts: list[str] = [] - for part in parts: - normalized = normalize_memory_namespace(part) - if not normalized: - continue - normalized_parts.extend( - segment for segment in normalized.split("/") if segment.strip() - ) - return "/".join(normalized_parts) - - -def memory_namespace_matches( - candidate: str, - namespace: str | None, - *, - include_descendants: bool, -) -> bool: - """Check whether a stored namespace belongs to the requested scope.""" - - if namespace is None: - return True - normalized_candidate = normalize_memory_namespace(candidate) - normalized_namespace = normalize_memory_namespace(namespace) - if not normalized_namespace: - return include_descendants or normalized_candidate == "" - if normalized_candidate == normalized_namespace: - return True - return include_descendants and normalized_candidate.startswith( - f"{normalized_namespace}/" - ) - - -def display_memory_namespace(value: Any) -> str | None: - """Return a user-facing namespace value.""" - - normalized = normalize_memory_namespace(value) - return normalized or None - - -def _memory_query_terms(value: str) -> list[str]: - normalized = re.sub(r"\s+", " ", str(value).strip().casefold()) - if not normalized: - return [] - terms = [item for item in re.findall(r"\w+", normalized, flags=re.UNICODE) if item] - if terms: - return terms - compact = normalized.replace(" ", "") - return [compact] if compact else [] - - -def memory_keyword_score(query: str, key: str, text: str) -> float: - """Score a keyword hit the same way across runtime and core bridge.""" - - normalized_query = str(query).casefold() - if not normalized_query: - return 1.0 - normalized_key = str(key).casefold() - normalized_text = str(text).casefold() - best = 0.0 - if normalized_query in normalized_key: - best = 1.0 - if normalized_query in normalized_text: - best = max(best, 0.92) - - terms = _memory_query_terms(normalized_query) - if not terms: - return best - - key_hits = sum(1 for term in terms if term in normalized_key) - text_hits = sum(1 for term in terms if term in normalized_text) - if key_hits: - best = max(best, 0.5 + 0.5 * (key_hits / len(terms))) - if text_hits: - best = max(best, 0.35 + 0.55 * (text_hits / len(terms))) - return min(best, 1.0) - - -def cosine_similarity(left: list[float], right: list[float]) -> float: - """Compute cosine similarity defensively for embedding vectors.""" - - if not left or not right or len(left) != len(right): - return 0.0 - left_norm = math.sqrt(sum(value * value for value in left)) - right_norm = math.sqrt(sum(value * value for value in right)) - if left_norm <= 0 or right_norm <= 0: - return 0.0 - return sum(a * b for a, b in zip(left, right, strict=False)) / ( - left_norm * right_norm - ) - - -def normalize_embedding(vector: list[float]) -> list[float]: - """Normalize an embedding for cosine/inner-product search.""" - - if not vector: - return [] - norm = math.sqrt(sum(value * value for value in vector)) - if norm <= 0: - return [0.0 for _ in vector] - return [float(value) / norm for value in vector] - - -def memory_index_entry(entry: Any, *, text: str) -> dict[str, Any]: - """Normalize cached sidecar data into a stable memory index record.""" - - if isinstance(entry, dict): - return { - "text": str(entry.get("text", text)), - "embedding": ( - [float(item) for item in entry.get("embedding", [])] - if isinstance(entry.get("embedding"), list) - else None - ), - "provider_id": ( - str(entry.get("provider_id")).strip() - if entry.get("provider_id") is not None - else None - ), - } - return {"text": text, "embedding": None, "provider_id": None} diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/plugin_ids.py b/astrbot-sdk/src/astrbot_sdk/_internal/plugin_ids.py deleted file mode 100644 index 4d6daa4df..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/plugin_ids.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_])?$") -_WINDOWS_RESERVED_PLUGIN_IDS = { - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", -} - - -def validate_plugin_id(plugin_id: str) -> str: - normalized = str(plugin_id).strip() - if not normalized: - raise ValueError("plugin_id must not be empty") - if not PLUGIN_ID_PATTERN.fullmatch(normalized): - raise ValueError( - "plugin_id must use only letters, digits, dots, underscores, or hyphens" - ) - if normalized.upper() in _WINDOWS_RESERVED_PLUGIN_IDS: - raise ValueError("plugin_id must not use a reserved Windows device name") - return normalized - - -def resolve_plugin_data_dir(root: Path, plugin_id: str) -> Path: - normalized = validate_plugin_id(plugin_id) - resolved_root = root.resolve() - candidate = (resolved_root / normalized).resolve() - try: - candidate.relative_to(resolved_root) - except ValueError as exc: - raise ValueError("plugin_id escapes the plugin data root") from exc - return candidate diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/plugin_logger.py b/astrbot-sdk/src/astrbot_sdk/_internal/plugin_logger.py deleted file mode 100644 index b89fb8dc1..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/plugin_logger.py +++ /dev/null @@ -1,313 +0,0 @@ -from __future__ import annotations - -import asyncio -import inspect -import os -import time -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - -try: - from astrbot.core.config.default import VERSION as _ASTRBOT_VERSION -except Exception: # noqa: BLE001 - _ASTRBOT_VERSION = "" - -__all__ = ["PluginLogEntry", "PluginLogger"] - - -@dataclass(slots=True) -class PluginLogEntry: - level: str - time: float - message: str - plugin_id: str - context: dict[str, Any] = field(default_factory=dict) - - -class _PluginLogBroker: - def __init__(self, plugin_id: str) -> None: - self.plugin_id = plugin_id - self._subscribers: set[asyncio.Queue[PluginLogEntry]] = set() - - def publish(self, entry: PluginLogEntry) -> None: - for queue in list(self._subscribers): - try: - queue.put_nowait(entry) - except asyncio.QueueFull: - continue - - async def watch(self) -> AsyncIterator[PluginLogEntry]: - queue: asyncio.Queue[PluginLogEntry] = asyncio.Queue() - self._subscribers.add(queue) - try: - while True: - yield await queue.get() - finally: - self._subscribers.discard(queue) - - -_BROKERS: dict[str, _PluginLogBroker] = {} - -_SHORT_LEVEL_NAMES = { - "DEBUG": "DBUG", - "INFO": "INFO", - "WARNING": "WARN", - "ERROR": "ERRO", - "CRITICAL": "CRIT", -} - -_ANSI_RESET = "\u001b[0m" -_ANSI_GREEN = "\u001b[32m" -_ANSI_LEVEL_COLORS = { - "DEBUG": "\u001b[1;34m", - "INFO": "\u001b[1;36m", - "WARNING": "\u001b[1;33m", - "ERROR": "\u001b[31m", - "CRITICAL": "\u001b[1;31m", -} - - -def _get_short_level_name(level_name: str) -> str: - return _SHORT_LEVEL_NAMES.get(level_name.upper(), level_name[:4].upper()) - - -def _build_source_file(pathname: str | None) -> str: - if not pathname: - return "unknown" - dirname = os.path.dirname(pathname) - return ( - os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "") - ) - - -def _plugin_tag_from_path(pathname: str | None) -> str: - if not pathname: - return "[Plug]" - norm_path = os.path.normpath(pathname) - if any( - marker in norm_path - for marker in ( - os.path.normpath("data/plugins"), - os.path.normpath("data/sdk_plugins"), - os.path.normpath("astrbot/builtin_stars"), - ) - ): - return "[Plug]" - return "[Core]" - - -def _level_color(level: str) -> str: - return _ANSI_LEVEL_COLORS.get(level.upper(), _ANSI_RESET) - - -def _get_broker(plugin_id: str) -> _PluginLogBroker: - broker = _BROKERS.get(plugin_id) - if broker is None: - broker = _PluginLogBroker(plugin_id) - _BROKERS[plugin_id] = broker - return broker - - -class PluginLogger: - def __init__( - self, - *, - plugin_id: str, - logger: Any, - bound_context: dict[str, Any] | None = None, - ) -> None: - self._plugin_id = plugin_id - self._logger = logger - self._broker = _get_broker(plugin_id) - self._bound_context = dict(bound_context or {}) - - @property - def plugin_id(self) -> str: - return self._plugin_id - - def bind(self, **kwargs: Any) -> PluginLogger: - bind = getattr(self._logger, "bind", None) - next_logger = self._logger - if callable(bind): - try: - next_logger = bind(**kwargs) - except Exception: - next_logger = self._logger - return PluginLogger( - plugin_id=self._plugin_id, - logger=next_logger, - bound_context={**self._bound_context, **kwargs}, - ) - - def opt(self, *args: Any, **kwargs: Any) -> PluginLogger: - opt = getattr(self._logger, "opt", None) - next_logger = self._logger - if callable(opt): - try: - next_logger = opt(*args, **kwargs) - except Exception: - next_logger = self._logger - return PluginLogger( - plugin_id=self._plugin_id, - logger=next_logger, - bound_context=self._bound_context, - ) - - async def watch(self) -> AsyncIterator[PluginLogEntry]: - async for entry in self._broker.watch(): - yield entry - - def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: - normalized_level = str(level).upper() - self._emit_console(normalized_level, message, *args, **kwargs) - self._publish(normalized_level, message, *args, **kwargs) - - def debug(self, message: Any, *args: Any, **kwargs: Any) -> None: - self._emit_console("DEBUG", message, *args, **kwargs) - self._publish("DEBUG", message, *args, **kwargs) - - def info(self, message: Any, *args: Any, **kwargs: Any) -> None: - self._emit_console("INFO", message, *args, **kwargs) - self._publish("INFO", message, *args, **kwargs) - - def warning(self, message: Any, *args: Any, **kwargs: Any) -> None: - self._emit_console("WARNING", message, *args, **kwargs) - self._publish("WARNING", message, *args, **kwargs) - - def error(self, message: Any, *args: Any, **kwargs: Any) -> None: - self._emit_console("ERROR", message, *args, **kwargs) - self._publish("ERROR", message, *args, **kwargs) - - def exception(self, message: Any, *args: Any, **kwargs: Any) -> None: - self._emit_console("ERROR", message, *args, exception=True, **kwargs) - self._publish("ERROR", message, *args, **kwargs) - - def _emit_console( - self, - level: str, - message: Any, - *args: Any, - exception: bool = False, - **kwargs: Any, - ) -> None: - if self._emit_console_with_opt( - level, - message, - *args, - exception=exception, - **kwargs, - ): - return - self._emit_console_fallback( - level, - message, - *args, - exception=exception, - **kwargs, - ) - - def _emit_console_with_opt( - self, - level: str, - message: Any, - *args: Any, - exception: bool = False, - **kwargs: Any, - ) -> bool: - opt = getattr(self._logger, "opt", None) - if not callable(opt): - return False - formatted_message = self._format_message(message, *args, **kwargs) - pathname, source_line = self._caller_info() - plugin_tag = _plugin_tag_from_path(pathname) - source_file = _build_source_file(pathname) - version_tag = ( - f" [v{_ASTRBOT_VERSION}]" - if _ASTRBOT_VERSION and level in {"WARNING", "ERROR", "CRITICAL"} - else "" - ) - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - level_text = _get_short_level_name(level) - level_color = _level_color(level) - line = ( - f"{_ANSI_GREEN}[{timestamp}]{_ANSI_RESET} {plugin_tag} " - f"{level_color}[{level_text}]{_ANSI_RESET}{version_tag} " - f"[{source_file}:{source_line}]: {level_color}{formatted_message}{_ANSI_RESET}" - ) - try: - emitter = opt(raw=True, exception=True) if exception else opt(raw=True) - log = getattr(emitter, "log", None) - if not callable(log): - return False - log(level, line + "\n") - return True - except Exception: - return False - - def _emit_console_fallback( - self, - level: str, - message: Any, - *args: Any, - exception: bool = False, - **kwargs: Any, - ) -> None: - method_names = [] - if exception: - method_names.append("exception") - method_names.append(str(level).lower()) - if exception: - method_names.append("error") - for method_name in method_names: - method = getattr(self._logger, method_name, None) - if not callable(method): - continue - try: - method(message, *args, **kwargs) - except Exception: - continue - return - log = getattr(self._logger, "log", None) - if callable(log): - try: - log(level, self._format_message(message, *args, **kwargs)) - except Exception: - return - - def _caller_info(self) -> tuple[str | None, int]: - frame = inspect.currentframe() - if frame is None: - return None, 0 - frame = frame.f_back - while frame is not None and frame.f_globals.get("__name__") == __name__: - frame = frame.f_back - if frame is None: - return None, 0 - return str(frame.f_code.co_filename), int(frame.f_lineno) - - def _publish(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: - entry = PluginLogEntry( - level=level, - time=time.time(), - message=self._format_message(message, *args, **kwargs), - plugin_id=self._plugin_id, - context=dict(self._bound_context), - ) - self._broker.publish(entry) - - @staticmethod - def _format_message(message: Any, *args: Any, **kwargs: Any) -> str: - if not isinstance(message, str): - return str(message) - text = message - if not args and not kwargs: - return text - try: - return text.format(*args, **kwargs) - except Exception: - return text - - def __getattr__(self, name: str) -> Any: - return getattr(self._logger, name) diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/star_runtime.py b/astrbot-sdk/src/astrbot_sdk/_internal/star_runtime.py deleted file mode 100644 index 37211735e..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/star_runtime.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager -from contextvars import ContextVar -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ..context import Context - from ..star import Star - - -_CURRENT_STAR_CONTEXT: ContextVar[Context | None] = ContextVar( - "astrbot_sdk_current_star_context", - default=None, -) -_CURRENT_STAR_INSTANCE: ContextVar[Star | None] = ContextVar( - "astrbot_sdk_current_star_instance", - default=None, -) - - -def current_star_context() -> Context | None: - return _CURRENT_STAR_CONTEXT.get() - - -def current_runtime_context() -> Context | None: - return _CURRENT_STAR_CONTEXT.get() - - -def current_star_instance() -> Star | None: - return _CURRENT_STAR_INSTANCE.get() - - -@contextmanager -def bind_star_runtime(star: Star | None, ctx: Context | None) -> Iterator[None]: - context_token = _CURRENT_STAR_CONTEXT.set(ctx) - star_token = _CURRENT_STAR_INSTANCE.set(star) - instance_token = star._bind_runtime_context(ctx) if star is not None else None - try: - yield - finally: - if star is not None and instance_token is not None: - star._reset_runtime_context(instance_token) - _CURRENT_STAR_INSTANCE.reset(star_token) - _CURRENT_STAR_CONTEXT.reset(context_token) diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/testing_support.py b/astrbot-sdk/src/astrbot_sdk/_internal/testing_support.py deleted file mode 100644 index a7e5e877c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/testing_support.py +++ /dev/null @@ -1,606 +0,0 @@ -"""Shared support primitives for local SDK testing.""" - -from __future__ import annotations - -import asyncio -import typing -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, TextIO - -from ..context import CancelToken -from ..context import Context as RuntimeContext -from ..events import MessageEvent -from ..protocol.messages import EventMessage, PeerInfo -from ..runtime._streaming import StreamExecution -from ..runtime.capability_router import CapabilityRouter - - -def _clone_payload_mapping(value: Any) -> dict[str, Any] | None: - if not isinstance(value, dict): - return None - return {str(key): item for key, item in value.items()} - - -@dataclass(slots=True) -class RecordedSend: - kind: str - message_id: str - session_id: str - text: str | None = None - image_url: str | None = None - chain: list[dict[str, Any]] | None = None - target: dict[str, Any] | None = None - raw: dict[str, Any] = field(default_factory=dict) - - @property - def session(self) -> str: - return self.session_id - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> RecordedSend: - if "text" in payload: - kind = "text" - elif "image_url" in payload: - kind = "image" - elif "chain" in payload: - kind = "chain" - else: - kind = "unknown" - return cls( - kind=kind, - message_id=str(payload.get("message_id", "")), - session_id=str(payload.get("session", "")), - text=payload.get("text") if isinstance(payload.get("text"), str) else None, - image_url=( - payload.get("image_url") - if isinstance(payload.get("image_url"), str) - else None - ), - chain=( - [dict(item) for item in payload.get("chain", [])] - if isinstance(payload.get("chain"), list) - else None - ), - target=_clone_payload_mapping(payload.get("target")), - raw=dict(payload), - ) - - -class StdoutPlatformSink: - def __init__(self, stream: TextIO | None = None) -> None: - self._stream = stream - self.records: list[RecordedSend] = [] - - def record(self, item: RecordedSend) -> None: - self.records.append(item) - if self._stream is None: - return - self._stream.write(self._format(item) + "\n") - self._stream.flush() - - def clear(self) -> None: - self.records.clear() - - def _format(self, item: RecordedSend) -> str: - if item.kind == "text": - return f"[text][{item.session_id}] {item.text or ''}" - if item.kind == "image": - return f"[image][{item.session_id}] {item.image_url or ''}" - if item.kind == "chain": - count = len(item.chain or []) - return f"[chain][{item.session_id}] {count} components" - return f"[send][{item.session_id}] {item.raw}" - - -class InMemoryDB: - def __init__(self, store: dict[str, Any]) -> None: - self._store = store - - def get(self, key: str, default: Any = None) -> Any: - return self._store.get(key, default) - - def set(self, key: str, value: Any) -> None: - self._store[key] = value - - def delete(self, key: str) -> None: - self._store.pop(key, None) - - def list(self, prefix: str | None = None) -> list[str]: - keys = sorted(self._store.keys()) - if prefix is None: - return keys - return [key for key in keys if key.startswith(prefix)] - - def get_many(self, keys: list[str]) -> list[dict[str, Any]]: - return [{"key": key, "value": self._store.get(key)} for key in keys] - - def set_many(self, items: list[dict[str, Any]]) -> None: - for item in items: - self.set(str(item.get("key", "")), item.get("value")) - - -class InMemoryMemory: - def __init__( - self, - store: dict[str, dict[str, Any]], - *, - expires_at: dict[str, datetime | None] | None = None, - ) -> None: - self._store = store - self._expires_at = expires_at if expires_at is not None else {} - - @staticmethod - def _is_ttl_entry(value: Any) -> bool: - """判断测试 memory 值是否使用 TTL 包装结构。 - - Args: - value: 待检查的存储值。 - - Returns: - bool: 如果包含 ``value`` 和 ``ttl_seconds`` 字段则返回 ``True``。 - """ - return isinstance(value, dict) and "value" in value and "ttl_seconds" in value - - @classmethod - def _search_text(cls, value: Any) -> str: - """提取测试用 memory.search 的匹配文本。 - - Args: - value: 当前存储的 memory 值。 - - Returns: - str: 用于本地测试搜索的文本内容。 - """ - if cls._is_ttl_entry(value): - value = value.get("value") - if not isinstance(value, dict): - return "" - for field_name in ("embedding_text", "content", "summary", "title", "text"): - item = value.get(field_name) - if isinstance(item, str) and item.strip(): - return item.strip() - return str(value) - - def _is_expired(self, key: str) -> bool: - """判断测试 memory 键是否已经过期。 - - Args: - key: memory 条目的键。 - - Returns: - bool: 如果当前时间已超过过期时间则返回 ``True``。 - """ - expires_at = self._expires_at.get(key) - return expires_at is not None and expires_at <= datetime.now(timezone.utc) - - def _purge_if_expired(self, key: str) -> bool: - """在测试 helper 中清理已过期的 memory 条目。 - - Args: - key: memory 条目的键。 - - Returns: - bool: 如果条目已过期并被清理则返回 ``True``。 - """ - if not self._is_expired(key): - return False - self._store.pop(key, None) - self._expires_at.pop(key, None) - return True - - def get(self, key: str, default: Any = None) -> Any: - if self._purge_if_expired(key): - return default - return self._store.get(key, default) - - def save(self, key: str, value: dict[str, Any]) -> None: - self._store[key] = dict(value) - - def delete(self, key: str) -> None: - self._store.pop(key, None) - self._expires_at.pop(key, None) - - def search(self, query: str) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - for key, value in list(self._store.items()): - if self._purge_if_expired(key): - continue - if query in key or query in self._search_text(value): - results.append({"key": key, "value": value}) - return results - - -class MockLLMClient: - def __init__(self, client: Any, router: MockCapabilityRouter) -> None: - self._client = client - self._router = router - - def mock_response(self, text: str) -> None: - self._router.enqueue_llm_response(text) - - def mock_stream_response(self, text: str) -> None: - self._router.enqueue_llm_stream_response(text) - - def clear_mock_responses(self) -> None: - self._router.clear_llm_responses() - - def __getattr__(self, name: str) -> Any: - return getattr(self._client, name) - - -class MockPlatformClient: - def __init__(self, client: Any, sink: StdoutPlatformSink) -> None: - self._client = client - self._sink = sink - - @property - def records(self) -> list[RecordedSend]: - return list(self._sink.records) - - def assert_sent( - self, - expected_text: str | None = None, - *, - kind: str = "text", - count: int | None = None, - ) -> None: - matched = [item for item in self._sink.records if item.kind == kind] - if expected_text is not None: - matched = [item for item in matched if item.text == expected_text] - if count is not None: - if len(matched) != count: - raise AssertionError( - f"expected {count} sent records, got {len(matched)}: {matched}" - ) - return - if not matched: - raise AssertionError( - f"expected sent record kind={kind!r} text={expected_text!r}, got {self._sink.records}" - ) - - def __getattr__(self, name: str) -> Any: - return getattr(self._client, name) - - -class MockCapabilityRouter(CapabilityRouter): - def __init__(self, *, platform_sink: StdoutPlatformSink | None = None) -> None: - self.platform_sink = platform_sink or StdoutPlatformSink() - self._llm_responses: list[str] = [] - self._llm_stream_responses: list[str] = [] - super().__init__() - self.db = InMemoryDB(self.db_store) - self.memory = InMemoryMemory( - self.memory_store, - expires_at=self._memory_expires_at, - ) - - def list_dynamic_command_routes(self, plugin_id: str) -> list[dict[str, Any]]: - return super().list_dynamic_command_routes(plugin_id) - - def remove_dynamic_command_routes_for_plugin(self, plugin_id: str) -> None: - super().remove_dynamic_command_routes_for_plugin(plugin_id) - - def emit_provider_change( - self, - provider_id: str, - provider_type: str, - umo: str | None = None, - ) -> None: - super().emit_provider_change(provider_id, provider_type, umo) - - def record_platform_error( - self, - platform_id: str, - message: str, - *, - traceback: str | None = None, - ) -> None: - super().record_platform_error(platform_id, message, traceback=traceback) - - def set_platform_stats(self, platform_id: str, stats: dict[str, Any]) -> None: - super().set_platform_stats(platform_id, stats) - - def enqueue_llm_response(self, text: str) -> None: - self._llm_responses.append(text) - - def enqueue_llm_stream_response(self, text: str) -> None: - self._llm_stream_responses.append(text) - - def clear_llm_responses(self) -> None: - self._llm_responses.clear() - self._llm_stream_responses.clear() - - async def execute( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool, - cancel_token, - request_id: str, - ) -> dict[str, Any] | StreamExecution: - if capability == "llm.chat": - return {"text": self._take_llm_response(str(payload.get("prompt", "")))} - if capability == "llm.chat_raw": - text = self._take_llm_response(str(payload.get("prompt", ""))) - return { - "text": text, - "usage": { - "input_tokens": len(str(payload.get("prompt", ""))), - "output_tokens": len(text), - }, - "finish_reason": "stop", - "tool_calls": [], - "role": "assistant", - "reasoning_content": None, - "reasoning_signature": None, - } - if capability == "llm.stream_chat": - text = self._take_llm_stream_response(str(payload.get("prompt", ""))) - - async def iterator() -> typing.AsyncIterator[dict[str, Any]]: - for char in text: - cancel_token.raise_if_cancelled() - await asyncio.sleep(0) - yield {"text": char} - - return StreamExecution( - iterator=iterator(), - finalize=lambda chunks: { - "text": "".join(item.get("text", "") for item in chunks) - }, - ) - before = len(self.sent_messages) - result = await super().execute( - capability, - payload, - stream=stream, - cancel_token=cancel_token, - request_id=request_id, - ) - self._flush_platform_records(before) - return result - - def _flush_platform_records(self, start_index: int) -> None: - for payload in self.sent_messages[start_index:]: - self.platform_sink.record(RecordedSend.from_payload(payload)) - - def _take_llm_response(self, prompt: str) -> str: - if self._llm_responses: - return self._llm_responses.pop(0) - return f"Echo: {prompt}" - - def _take_llm_stream_response(self, prompt: str) -> str: - if self._llm_stream_responses: - return self._llm_stream_responses.pop(0) - if self._llm_responses: - return self._llm_responses.pop(0) - return f"Echo: {prompt}" - - -class MockPeer: - def __init__(self, router: MockCapabilityRouter) -> None: - self._router = router - self._counter = 0 - self.remote_peer = PeerInfo( - name="astrbot-local-core", - role="core", - version="local", - ) - self.remote_capabilities = list(router.descriptors()) - self.remote_capability_map = { - item.name: item for item in self.remote_capabilities - } - self.remote_handlers: list[Any] = [] - self.remote_provided_capabilities: list[Any] = [] - self.remote_metadata = {"mode": "local"} - - async def invoke( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, Any]: - if stream: - raise ValueError("stream=True 请使用 invoke_stream()") - return typing.cast( - dict[str, Any], - await self._router.execute( - capability, - payload, - stream=False, - cancel_token=CancelToken(), - request_id=request_id or self._next_id(), - ), - ) - - async def invoke_stream( - self, - capability: str, - payload: dict[str, Any], - *, - request_id: str | None = None, - include_completed: bool = False, - ): - request_id = request_id or self._next_id() - execution = typing.cast( - StreamExecution, - await self._router.execute( - capability, - payload, - stream=True, - cancel_token=CancelToken(), - request_id=request_id, - ), - ) - - async def iterator(): - yield EventMessage.model_validate({"id": request_id, "phase": "started"}) - chunks: list[dict[str, Any]] = [] - async for chunk in execution.iterator: - if execution.collect_chunks: - chunks.append(chunk) - yield EventMessage.model_validate( - {"id": request_id, "phase": "delta", "data": chunk} - ) - output = execution.finalize(chunks) - if include_completed: - yield EventMessage.model_validate( - {"id": request_id, "phase": "completed", "output": output} - ) - - return iterator() - - def _next_id(self) -> str: - self._counter += 1 - return f"local_{self._counter:04d}" - - -def _normalize_plugin_metadata( - plugin_id: str, - plugin_metadata: Mapping[str, Any] | None, -) -> dict[str, Any]: - if plugin_metadata is None: - plugin_metadata = {} - declared_name = plugin_metadata.get("name") - if declared_name is not None and str(declared_name) != plugin_id: - raise ValueError( - "MockContext.plugin_metadata['name'] 必须与 plugin_id 一致," - f"当前收到 {declared_name!r} != {plugin_id!r}" - ) - description = plugin_metadata.get("description") - if description is None: - description = plugin_metadata.get("desc", "") - return { - "name": plugin_id, - "display_name": str(plugin_metadata.get("display_name") or plugin_id), - "description": str(description or ""), - "author": str(plugin_metadata.get("author") or ""), - "version": str(plugin_metadata.get("version") or "0.0.0"), - "enabled": bool(plugin_metadata.get("enabled", True)), - "reserved": bool(plugin_metadata.get("reserved", False)), - "acknowledge_global_mcp_risk": bool( - plugin_metadata.get("acknowledge_global_mcp_risk", False) - ), - "local_mcp_servers": ( - { - str(server_name): dict(server_payload) - for server_name, server_payload in plugin_metadata.get( - "local_mcp_servers", - {}, - ).items() - if str(server_name).strip() and isinstance(server_payload, dict) - } - if isinstance(plugin_metadata.get("local_mcp_servers"), dict) - else {} - ), - "support_platforms": [ - str(item) - for item in plugin_metadata.get("support_platforms", []) - if isinstance(item, str) - ] - if isinstance(plugin_metadata.get("support_platforms"), list) - else [], - "astrbot_version": ( - str(plugin_metadata.get("astrbot_version")) - if plugin_metadata.get("astrbot_version") is not None - else None - ), - } - - -class MockContext(RuntimeContext): - def __init__( - self, - *, - plugin_id: str = "test-plugin", - logger: Any | None = None, - cancel_token: CancelToken | None = None, - platform_sink: StdoutPlatformSink | None = None, - plugin_metadata: Mapping[str, Any] | None = None, - ) -> None: - self.platform_sink = platform_sink or StdoutPlatformSink() - self.router = MockCapabilityRouter(platform_sink=self.platform_sink) - self.mock_peer = MockPeer(self.router) - super().__init__( - peer=self.mock_peer, - plugin_id=plugin_id, - cancel_token=cancel_token, - logger=logger, - ) - self.router.upsert_plugin( - metadata=_normalize_plugin_metadata(plugin_id, plugin_metadata), - config={}, - ) - self.llm = MockLLMClient(self.llm, self.router) - self.platform = MockPlatformClient(self.platform, self.platform_sink) - - @property - def sent_messages(self) -> list[RecordedSend]: - return list(self.platform_sink.records) - - @property - def event_actions(self) -> list[dict[str, Any]]: - return list(self.router.event_actions) - - -class MockMessageEvent(MessageEvent): - def __init__( - self, - *, - text: str = "", - user_id: str | None = "test-user", - group_id: str | None = None, - platform: str | None = "test", - session_id: str | None = "test-session", - raw: dict[str, Any] | None = None, - context: MockContext | None = None, - ) -> None: - self.replies: list[str] = [] - super().__init__( - text=text, - user_id=user_id, - group_id=group_id, - platform=platform, - session_id=session_id, - raw=raw, - context=context, - ) - if context is not None: - self.bind_runtime_reply(context) - elif self._reply_handler is None: - self.bind_reply_handler(self._capture_reply) - - @property - def is_private(self) -> bool: - return self.group_id is None - - def bind_runtime_reply(self, context: MockContext) -> None: - self._context = context - - async def reply(text: str) -> None: - self.replies.append(text) - await context.platform.send(self.session_ref or self.session_id, text) - - self.bind_reply_handler(reply) - - async def _capture_reply(self, text: str) -> None: - self.replies.append(text) - - -__all__ = [ - "InMemoryDB", - "InMemoryMemory", - "MockCapabilityRouter", - "MockContext", - "MockLLMClient", - "MockMessageEvent", - "MockPeer", - "MockPlatformClient", - "RecordedSend", - "StdoutPlatformSink", -] diff --git a/astrbot-sdk/src/astrbot_sdk/_internal/typing_utils.py b/astrbot-sdk/src/astrbot_sdk/_internal/typing_utils.py deleted file mode 100644 index 7cac7421b..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_internal/typing_utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import typing -from types import UnionType -from typing import Any - - -def unwrap_optional(annotation: Any) -> tuple[Any, bool]: - origin = typing.get_origin(annotation) - if origin in {typing.Union, UnionType}: - args = [item for item in typing.get_args(annotation) if item is not type(None)] - if len(args) == 1: - return args[0], True - return annotation, False - - -__all__ = ["unwrap_optional"] diff --git a/astrbot-sdk/src/astrbot_sdk/_memory_backend.py b/astrbot-sdk/src/astrbot_sdk/_memory_backend.py deleted file mode 100644 index 848cd4c97..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_memory_backend.py +++ /dev/null @@ -1,1325 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import re -import sqlite3 -import threading -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, cast - -from ._internal.memory_utils import ( - cosine_similarity, - display_memory_namespace, - extract_memory_text, - join_memory_namespace, - memory_keyword_score, - memory_namespace_matches, - memory_value_for_search, - normalize_embedding, - normalize_memory_namespace, -) - - -def _utcnow() -> datetime: - # Centralize time access so expiry tests can advance time without mutating SQLite internals. - return datetime.now(timezone.utc) - - -def _sql_placeholders(count: int) -> str: - if count <= 0: - raise ValueError("count must be positive") - return ", ".join("?" for _ in range(count)) - - -def _normalize_scope_namespace(namespace: str | None) -> str | None: - if namespace is None: - return None - return normalize_memory_namespace(namespace) - - -def _escape_like_value(value: str) -> str: - return str(value).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - - -EmbedMany = Callable[[list[str]], Awaitable[list[list[float]]] | list[list[float]]] -EmbedOne = Callable[[str], Awaitable[list[float]] | list[float]] - - -@dataclass(slots=True) -class MemorySearchResult: - key: str - namespace: str - value: dict[str, Any] | None - score: float - match_type: str - - def to_payload(self) -> dict[str, Any]: - payload: dict[str, Any] = { - "key": self.key, - "value": self.value, - "score": self.score, - "match_type": self.match_type, - } - namespace = display_memory_namespace(self.namespace) - if namespace is not None: - payload["namespace"] = namespace - return payload - - -@dataclass(slots=True) -class _StoredRecord: - namespace: str - key: str - stored: dict[str, Any] - search_text: str - updated_at: str - - -@dataclass(slots=True) -class _VectorCandidate: - namespace: str - key: str - stored: dict[str, Any] - search_text: str - score: float - - -class PluginMemoryBackend: - """Persistent plugin-scoped memory backend with namespace-aware search.""" - - def __init__(self, data_dir: Path) -> None: - self._base_dir = Path(data_dir) / "memory" - self._db_path = self._base_dir / "memory.sqlite3" - self._vector_dir = self._base_dir / "vectors" - self._lock = threading.RLock() - self._initialized = False - self._fts_enabled = False - self._vector_indexes: dict[str, Any | None] = {} - self._vector_fallbacks: dict[str, list[tuple[int, list[float]]]] = {} - - async def save( - self, - key: str, - value: dict[str, Any], - *, - namespace: str | None = None, - ) -> None: - await asyncio.to_thread( - self._save_sync, - str(key), - dict(value), - normalize_memory_namespace(namespace), - None, - ) - - async def save_with_ttl( - self, - key: str, - value: dict[str, Any], - ttl_seconds: int, - *, - namespace: str | None = None, - ) -> None: - expires_at = _utcnow().timestamp() + max(int(ttl_seconds), 0) - await asyncio.to_thread( - self._save_sync, - str(key), - dict(value), - normalize_memory_namespace(namespace), - { - "ttl_seconds": int(ttl_seconds), - "expires_at": datetime.fromtimestamp( - expires_at, - tz=timezone.utc, - ).isoformat(), - }, - ) - - async def get( - self, - key: str, - *, - namespace: str | None = None, - ) -> dict[str, Any] | None: - return await asyncio.to_thread( - self._get_sync, - str(key), - normalize_memory_namespace(namespace), - ) - - async def get_many( - self, - keys: list[str], - *, - namespace: str | None = None, - ) -> list[dict[str, Any]]: - normalized_namespace = normalize_memory_namespace(namespace) - return await asyncio.to_thread( - self._get_many_sync, - [str(item) for item in keys], - normalized_namespace, - ) - - async def delete( - self, - key: str, - *, - namespace: str | None = None, - ) -> bool: - return await asyncio.to_thread( - self._delete_sync, - str(key), - normalize_memory_namespace(namespace), - ) - - async def delete_many( - self, - keys: list[str], - *, - namespace: str | None = None, - ) -> int: - normalized_namespace = normalize_memory_namespace(namespace) - return await asyncio.to_thread( - self._delete_many_sync, - [str(item) for item in keys], - normalized_namespace, - ) - - async def stats( - self, - *, - namespace: str | None = None, - include_descendants: bool = True, - ) -> dict[str, Any]: - normalized_namespace = _normalize_scope_namespace(namespace) - return await asyncio.to_thread( - self._stats_sync, - normalized_namespace, - bool(include_descendants), - ) - - async def search( - self, - query: str, - *, - namespace: str | None = None, - include_descendants: bool = True, - mode: str, - limit: int | None, - min_score: float | None, - provider_id: str | None = None, - embed_one: EmbedOne | None = None, - embed_many: EmbedMany | None = None, - ) -> list[dict[str, Any]]: - normalized_namespace = _normalize_scope_namespace(namespace) - normalized_mode = str(mode).strip().lower() or "keyword" - query_text = str(query) - - await asyncio.to_thread(self._purge_expired_sync) - - keyword_candidates = await asyncio.to_thread( - self._keyword_candidates_sync, - query_text, - normalized_namespace, - bool(include_descendants), - limit, - ) - - vector_candidates: list[_VectorCandidate] = [] - if normalized_mode in {"vector", "hybrid"} and provider_id: - await self._ensure_embeddings( - provider_id=provider_id, - namespace=normalized_namespace, - include_descendants=bool(include_descendants), - embed_one=embed_one, - embed_many=embed_many, - ) - if embed_one is not None: - raw_query_embedding = await _maybe_await(embed_one(query_text)) - query_embedding = normalize_embedding( - [float(item) for item in raw_query_embedding] - ) - vector_candidates = await asyncio.to_thread( - self._vector_candidates_sync, - provider_id, - query_embedding, - normalized_namespace, - bool(include_descendants), - limit, - ) - - merged: dict[tuple[str, str], dict[str, Any]] = {} - for record in keyword_candidates: - identity = (record.namespace, record.key) - merged[identity] = { - "namespace": record.namespace, - "key": record.key, - "stored": record.stored, - "keyword_score": memory_keyword_score( - query_text, - record.key, - record.search_text, - ), - "vector_score": 0.0, - } - for record in vector_candidates: - identity = (record.namespace, record.key) - current = merged.setdefault( - identity, - { - "namespace": record.namespace, - "key": record.key, - "stored": record.stored, - "keyword_score": memory_keyword_score( - query_text, - record.key, - record.search_text, - ), - "vector_score": 0.0, - }, - ) - current["vector_score"] = max( - float(current["vector_score"]), - float(record.score), - ) - - results: list[MemorySearchResult] = [] - for item in merged.values(): - keyword_score = max(0.0, float(item["keyword_score"])) - vector_score = max(0.0, float(item["vector_score"])) - score = self._combined_score( - mode=normalized_mode, - keyword_score=keyword_score, - vector_score=vector_score, - ) - if score <= 0: - continue - if min_score is not None and score < float(min_score): - continue - - if normalized_mode == "keyword" or ( - keyword_score > 0 and vector_score <= 0 - ): - match_type = "keyword" - elif normalized_mode == "vector" or keyword_score <= 0: - match_type = "vector" - else: - match_type = "hybrid" - - results.append( - MemorySearchResult( - key=str(item["key"]), - namespace=str(item["namespace"]), - value=memory_value_for_search(item["stored"]), - score=score, - match_type=match_type, - ) - ) - - results.sort(key=lambda item: (-item.score, item.namespace, item.key)) - if limit is not None and limit >= 0: - results = results[:limit] - return [item.to_payload() for item in results] - - async def _ensure_embeddings( - self, - *, - provider_id: str, - namespace: str | None, - include_descendants: bool, - embed_one: EmbedOne | None, - embed_many: EmbedMany | None, - ) -> None: - missing = await asyncio.to_thread( - self._missing_embeddings_sync, - provider_id, - namespace, - include_descendants, - ) - if missing: - texts = [record.search_text for record in missing] - embeddings: list[list[float]] - if embed_many is not None: - raw_embeddings = await _maybe_await(embed_many(texts)) - embeddings = [ - normalize_embedding([float(value) for value in item]) - for item in raw_embeddings - ] - elif embed_one is not None: - embeddings = [] - for text in texts: - raw_vector = await _maybe_await(embed_one(text)) - embeddings.append( - normalize_embedding([float(value) for value in raw_vector]) - ) - else: - embeddings = [] - await asyncio.to_thread( - self._upsert_embeddings_sync, - provider_id, - missing, - embeddings, - ) - await asyncio.to_thread(self._ensure_vector_index_sync, provider_id) - - def _save_sync( - self, - key: str, - value: dict[str, Any], - namespace: str, - ttl_metadata: dict[str, Any] | None, - ) -> None: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - stored = dict(value) - expires_at: str | None = None - if ttl_metadata is not None: - expires_at = str(ttl_metadata.get("expires_at", "")).strip() or None - stored = { - "value": dict(value), - "ttl_seconds": int(ttl_metadata.get("ttl_seconds", 0)), - } - if expires_at is not None: - stored["expires_at"] = expires_at - search_text = extract_memory_text(stored) - stored_json = json.dumps( - stored, - ensure_ascii=False, - sort_keys=True, - default=str, - ) - updated_at = _utcnow().isoformat() - conn.execute( - """ - INSERT INTO memory_records(namespace, key, stored_json, search_text, expires_at, updated_at) - VALUES(?, ?, ?, ?, ?, ?) - ON CONFLICT(namespace, key) DO UPDATE SET - stored_json = excluded.stored_json, - search_text = excluded.search_text, - expires_at = excluded.expires_at, - updated_at = excluded.updated_at - """, - (namespace, key, stored_json, search_text, expires_at, updated_at), - ) - self._sync_fts_row_locked( - conn, - namespace=namespace, - key=key, - search_text=search_text, - ) - provider_rows = conn.execute( - """ - SELECT DISTINCT provider_id - FROM memory_embeddings - WHERE namespace = ? AND key = ? - """, - (namespace, key), - ).fetchall() - conn.execute( - "DELETE FROM memory_embeddings WHERE namespace = ? AND key = ?", - (namespace, key), - ) - for row in provider_rows: - provider_id = str(row[0]).strip() - if provider_id: - self._mark_vector_dirty_locked(conn, provider_id) - conn.commit() - finally: - conn.close() - - def _get_sync(self, key: str, namespace: str) -> dict[str, Any] | None: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - row = conn.execute( - """ - SELECT stored_json - FROM memory_records - WHERE namespace = ? AND key = ? - """, - (namespace, key), - ).fetchone() - if row is None: - return None - stored = self._load_stored_json(row[0]) - return memory_value_for_search(stored) - finally: - conn.close() - - def _get_many_sync(self, keys: list[str], namespace: str) -> list[dict[str, Any]]: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - if not keys: - return [] - lookup_keys = list(dict.fromkeys(keys)) - placeholders = _sql_placeholders(len(lookup_keys)) - rows = conn.execute( - f""" - SELECT key, stored_json - FROM memory_records - WHERE namespace = ? AND key IN ({placeholders}) - """, - (namespace, *lookup_keys), - ).fetchall() - stored_by_key = { - str(row[0]): self._load_stored_json(row[1]) for row in rows - } - return [ - { - "key": key, - "value": memory_value_for_search(stored_by_key.get(key)), - } - for key in keys - ] - finally: - conn.close() - - def _delete_sync(self, key: str, namespace: str) -> bool: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - deleted = self._delete_record_locked(conn, namespace=namespace, key=key) - conn.commit() - return deleted - finally: - conn.close() - - def _delete_many_sync(self, keys: list[str], namespace: str) -> int: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - unique_keys = list(dict.fromkeys(keys)) - if not unique_keys: - conn.commit() - return 0 - placeholders = _sql_placeholders(len(unique_keys)) - provider_rows = conn.execute( - f""" - SELECT DISTINCT provider_id - FROM memory_embeddings - WHERE namespace = ? AND key IN ({placeholders}) - """, - (namespace, *unique_keys), - ).fetchall() - conn.execute( - f"DELETE FROM memory_embeddings WHERE namespace = ? AND key IN ({placeholders})", - (namespace, *unique_keys), - ) - deleted = conn.execute( - f"DELETE FROM memory_records WHERE namespace = ? AND key IN ({placeholders})", - (namespace, *unique_keys), - ).rowcount - if self._fts_enabled: - conn.execute( - f"DELETE FROM memory_records_fts WHERE namespace = ? AND key IN ({placeholders})", - (namespace, *unique_keys), - ) - for row in provider_rows: - provider_id = str(row[0]).strip() - if provider_id: - self._mark_vector_dirty_locked(conn, provider_id) - conn.commit() - return deleted - finally: - conn.close() - - def _stats_sync( - self, - namespace: str | None, - include_descendants: bool, - ) -> dict[str, Any]: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - where_sql, params = self._namespace_where( - namespace, - include_descendants=include_descendants, - ) - total_items = int( - conn.execute( - f"SELECT COUNT(*) FROM memory_records WHERE {where_sql}", - params, - ).fetchone()[0] - ) - ttl_entries = int( - conn.execute( - f""" - SELECT COUNT(*) - FROM memory_records - WHERE {where_sql} AND expires_at IS NOT NULL - """, - params, - ).fetchone()[0] - ) - total_bytes = int( - conn.execute( - f""" - SELECT COALESCE(SUM(LENGTH(key) + LENGTH(stored_json)), 0) - FROM memory_records - WHERE {where_sql} - """, - params, - ).fetchone()[0] - ) - namespace_count = int( - conn.execute( - f""" - SELECT COUNT(DISTINCT namespace) - FROM memory_records - WHERE {where_sql} - """, - params, - ).fetchone()[0] - ) - embedding_where_sql, embedding_params = self._namespace_where( - namespace, - include_descendants=include_descendants, - alias="e", - ) - embedded_items = int( - conn.execute( - f""" - SELECT COUNT(*) - FROM ( - SELECT DISTINCT e.namespace, e.key - FROM memory_embeddings e - WHERE {embedding_where_sql} - ) - """, - embedding_params, - ).fetchone()[0] - ) - indexed_items = total_items - dirty_items = max(indexed_items - embedded_items, 0) - provider_rows = conn.execute( - """ - SELECT provider_id, dirty - FROM memory_vector_state - ORDER BY provider_id - """ - ).fetchall() - return { - "total_items": total_items, - "total_bytes": total_bytes, - "ttl_entries": ttl_entries, - "namespace": ( - None - if namespace is None - else normalize_memory_namespace(namespace) - ), - "namespace_count": namespace_count, - "indexed_items": indexed_items, - "embedded_items": embedded_items, - "dirty_items": dirty_items, - "fts_enabled": self._fts_enabled, - "vector_backend": self._vector_backend_label(), - "vector_indexes": [ - { - "provider_id": str(provider_id), - "dirty": bool(dirty), - } - for provider_id, dirty in provider_rows - ], - } - finally: - conn.close() - - def _keyword_candidates_sync( - self, - query: str, - namespace: str | None, - include_descendants: bool, - limit: int | None, - ) -> list[_StoredRecord]: - with self._lock: - conn = self._connect() - try: - fetch_limit = max((int(limit) if limit is not None else 10) * 8, 50) - where_sql, params = self._namespace_where( - namespace, - include_descendants=include_descendants, - ) - seen: set[tuple[str, str]] = set() - records: list[_StoredRecord] = [] - fts_query = self._fts_query(query) - if self._fts_enabled and fts_query is not None: - fts_where_sql, fts_params = self._namespace_where( - namespace, - include_descendants=include_descendants, - alias="r", - ) - rows = conn.execute( - f""" - SELECT r.namespace, r.key, r.stored_json, r.search_text, r.updated_at - FROM memory_records_fts f - JOIN memory_records r - ON r.namespace = f.namespace AND r.key = f.key - WHERE {fts_where_sql} AND memory_records_fts MATCH ? - ORDER BY bm25(memory_records_fts), r.updated_at DESC - LIMIT ? - """, - (*fts_params, fts_query, fetch_limit), - ).fetchall() - for row in rows: - record = self._stored_record_from_row(row) - identity = (record.namespace, record.key) - if identity not in seen: - seen.add(identity) - records.append(record) - - like_query = f"%{str(query).strip()}%" - if not records or len(records) < fetch_limit: - rows = conn.execute( - f""" - SELECT namespace, key, stored_json, search_text, updated_at - FROM memory_records - WHERE {where_sql} - AND (? = '%%' OR key LIKE ? COLLATE NOCASE OR search_text LIKE ? COLLATE NOCASE) - ORDER BY updated_at DESC - LIMIT ? - """, - (*params, like_query, like_query, like_query, fetch_limit), - ).fetchall() - for row in rows: - record = self._stored_record_from_row(row) - identity = (record.namespace, record.key) - if identity not in seen: - seen.add(identity) - records.append(record) - return records - finally: - conn.close() - - def _missing_embeddings_sync( - self, - provider_id: str, - namespace: str | None, - include_descendants: bool, - ) -> list[_StoredRecord]: - with self._lock: - conn = self._connect() - try: - where_sql, params = self._namespace_where( - namespace, - include_descendants=include_descendants, - alias="r", - ) - rows = conn.execute( - f""" - SELECT r.namespace, r.key, r.stored_json, r.search_text, r.updated_at - FROM memory_records r - LEFT JOIN memory_embeddings e - ON e.namespace = r.namespace - AND e.key = r.key - AND e.provider_id = ? - WHERE {where_sql} AND e.id IS NULL - ORDER BY r.updated_at DESC - """, - (provider_id, *params), - ).fetchall() - return [self._stored_record_from_row(row) for row in rows] - finally: - conn.close() - - def _upsert_embeddings_sync( - self, - provider_id: str, - records: list[_StoredRecord], - embeddings: list[list[float]], - ) -> None: - if not records: - return - with self._lock: - conn = self._connect() - try: - for index, record in enumerate(records): - vector = embeddings[index] if index < len(embeddings) else [] - conn.execute( - """ - INSERT INTO memory_embeddings(namespace, key, provider_id, embedding_json, updated_at) - VALUES(?, ?, ?, ?, ?) - ON CONFLICT(namespace, key, provider_id) DO UPDATE SET - embedding_json = excluded.embedding_json, - updated_at = excluded.updated_at - """, - ( - record.namespace, - record.key, - provider_id, - json.dumps( - vector, ensure_ascii=False, separators=(",", ":") - ), - _utcnow().isoformat(), - ), - ) - self._mark_vector_dirty_locked(conn, provider_id) - conn.commit() - finally: - conn.close() - - def _vector_candidates_sync( - self, - provider_id: str, - query_embedding: list[float], - namespace: str | None, - include_descendants: bool, - limit: int | None, - ) -> list[_VectorCandidate]: - if not query_embedding: - return [] - with self._lock: - conn = self._connect() - try: - index = self._vector_indexes.get(provider_id) - fetch_limit = max((int(limit) if limit is not None else 10) * 10, 50) - if index is not None and self._faiss_available(): - return self._faiss_vector_candidates_locked( - conn=conn, - provider_id=provider_id, - query_embedding=query_embedding, - namespace=namespace, - include_descendants=include_descendants, - fetch_limit=fetch_limit, - ) - return self._fallback_vector_candidates_locked( - conn=conn, - provider_id=provider_id, - query_embedding=query_embedding, - namespace=namespace, - include_descendants=include_descendants, - fetch_limit=fetch_limit, - ) - finally: - conn.close() - - def _ensure_vector_index_sync(self, provider_id: str) -> None: - with self._lock: - conn = self._connect() - try: - self._init_storage_locked(conn) - row = conn.execute( - """ - SELECT dirty - FROM memory_vector_state - WHERE provider_id = ? - """, - (provider_id,), - ).fetchone() - dirty = True if row is None else bool(row[0]) - if not dirty and provider_id in self._vector_indexes: - return - - index_path = ( - self._vector_dir / f"{self._safe_filename(provider_id)}.faiss" - ) - if not dirty and index_path.exists() and self._faiss_available(): - try: - faiss = self._import_faiss() - self._vector_indexes[provider_id] = faiss.read_index( - str(index_path) - ) - self._vector_fallbacks.pop(provider_id, None) - return - except Exception: - pass - - rows = conn.execute( - """ - SELECT id, embedding_json - FROM memory_embeddings - WHERE provider_id = ? - ORDER BY id - """, - (provider_id,), - ).fetchall() - ids: list[int] = [] - vectors: list[list[float]] = [] - for raw_id, raw_vector in rows: - vector = self._load_embedding_json(raw_vector) - if not vector: - continue - ids.append(int(raw_id)) - vectors.append(vector) - - if self._faiss_available() and vectors: - faiss = self._import_faiss() - np = self._import_numpy() - dimension = len(vectors[0]) - base_index = faiss.IndexFlatIP(dimension) - index = faiss.IndexIDMap2(base_index) - index.add_with_ids( - np.array(vectors, dtype="float32"), - np.array(ids, dtype="int64"), - ) - self._vector_indexes[provider_id] = index - self._vector_fallbacks.pop(provider_id, None) - self._vector_dir.mkdir(parents=True, exist_ok=True) - faiss.write_index(index, str(index_path)) - else: - self._vector_indexes[provider_id] = None - self._vector_fallbacks[provider_id] = list( - zip(ids, vectors, strict=False) - ) - conn.execute( - """ - INSERT INTO memory_vector_state(provider_id, dirty, updated_at) - VALUES(?, 0, ?) - ON CONFLICT(provider_id) DO UPDATE SET - dirty = 0, - updated_at = excluded.updated_at - """, - (provider_id, _utcnow().isoformat()), - ) - conn.commit() - finally: - conn.close() - - def _faiss_vector_candidates_locked( - self, - *, - conn: sqlite3.Connection, - provider_id: str, - query_embedding: list[float], - namespace: str | None, - include_descendants: bool, - fetch_limit: int, - ) -> list[_VectorCandidate]: - index = self._vector_indexes.get(provider_id) - if index is None: - return [] - np = self._import_numpy() - total_count = int(getattr(index, "ntotal", 0) or 0) - if total_count <= 0: - return [] - - collected: list[_VectorCandidate] = [] - seen: set[tuple[str, str]] = set() - current_limit = min(fetch_limit, total_count) - while current_limit > 0: - scores, ids = index.search( - np.array([query_embedding], dtype="float32"), - current_limit, - ) - raw_ids = [int(item) for item in ids[0] if int(item) >= 0] - score_map = { - int(item_id): max(0.0, float(score)) - for item_id, score in zip(raw_ids, scores[0], strict=False) - } - if not score_map: - break - placeholders = ",".join("?" for _ in score_map) - rows = conn.execute( - f""" - SELECT e.id, r.namespace, r.key, r.stored_json, r.search_text - FROM memory_embeddings e - JOIN memory_records r - ON r.namespace = e.namespace AND r.key = e.key - WHERE e.provider_id = ? - AND e.id IN ({placeholders}) - """, - (provider_id, *score_map.keys()), - ).fetchall() - row_map = {int(row[0]): row for row in rows} - for item_id in raw_ids: - row = row_map.get(item_id) - if row is None: - continue - record_namespace = normalize_memory_namespace(row[1]) - if not memory_namespace_matches( - record_namespace, - namespace, - include_descendants=include_descendants, - ): - continue - identity = (record_namespace, str(row[2])) - if identity in seen: - continue - seen.add(identity) - collected.append( - _VectorCandidate( - namespace=record_namespace, - key=str(row[2]), - stored=self._load_stored_json(row[3]), - search_text=str(row[4]), - score=max(0.0, score_map.get(item_id, 0.0)), - ) - ) - if len(collected) >= fetch_limit or current_limit >= total_count: - break - next_limit = min(total_count, current_limit * 2) - if next_limit == current_limit: - break - current_limit = next_limit - return collected - - def _fallback_vector_candidates_locked( - self, - *, - conn: sqlite3.Connection, - provider_id: str, - query_embedding: list[float], - namespace: str | None, - include_descendants: bool, - fetch_limit: int, - ) -> list[_VectorCandidate]: - rows = conn.execute( - """ - SELECT e.namespace, e.key, e.embedding_json, r.stored_json, r.search_text - FROM memory_embeddings e - JOIN memory_records r - ON r.namespace = e.namespace AND r.key = e.key - WHERE e.provider_id = ? - """, - (provider_id,), - ).fetchall() - candidates: list[_VectorCandidate] = [] - for raw_namespace, raw_key, raw_embedding, raw_stored, raw_search_text in rows: - record_namespace = normalize_memory_namespace(raw_namespace) - if not memory_namespace_matches( - record_namespace, - namespace, - include_descendants=include_descendants, - ): - continue - embedding = self._load_embedding_json(raw_embedding) - score = max(0.0, cosine_similarity(query_embedding, embedding)) - if score <= 0: - continue - candidates.append( - _VectorCandidate( - namespace=record_namespace, - key=str(raw_key), - stored=self._load_stored_json(raw_stored), - search_text=str(raw_search_text), - score=score, - ) - ) - candidates.sort(key=lambda item: (-item.score, item.namespace, item.key)) - return candidates[:fetch_limit] - - def _purge_expired_sync(self) -> None: - with self._lock: - conn = self._connect() - try: - self._purge_expired_locked(conn) - conn.commit() - finally: - conn.close() - - def _purge_expired_locked(self, conn: sqlite3.Connection) -> None: - self._init_storage_locked(conn) - now_iso = _utcnow().isoformat() - rows = conn.execute( - """ - SELECT namespace, key - FROM memory_records - WHERE expires_at IS NOT NULL AND expires_at <= ? - """, - (now_iso,), - ).fetchall() - for namespace, key in rows: - self._delete_record_locked( - conn, - namespace=normalize_memory_namespace(namespace), - key=str(key), - ) - - def _delete_record_locked( - self, - conn: sqlite3.Connection, - *, - namespace: str, - key: str, - ) -> bool: - provider_rows = conn.execute( - """ - SELECT DISTINCT provider_id - FROM memory_embeddings - WHERE namespace = ? AND key = ? - """, - (namespace, key), - ).fetchall() - conn.execute( - "DELETE FROM memory_embeddings WHERE namespace = ? AND key = ?", - (namespace, key), - ) - deleted = ( - conn.execute( - "DELETE FROM memory_records WHERE namespace = ? AND key = ?", - (namespace, key), - ).rowcount - > 0 - ) - if self._fts_enabled: - conn.execute( - "DELETE FROM memory_records_fts WHERE namespace = ? AND key = ?", - (namespace, key), - ) - for row in provider_rows: - provider_id = str(row[0]).strip() - if provider_id: - self._mark_vector_dirty_locked(conn, provider_id) - return deleted - - def _connect(self) -> sqlite3.Connection: - self._base_dir.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(self._db_path) - conn.row_factory = sqlite3.Row - self._init_storage_locked(conn) - return conn - - def _init_storage_locked(self, conn: sqlite3.Connection) -> None: - if self._initialized: - return - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS memory_records ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - stored_json TEXT NOT NULL, - search_text TEXT NOT NULL, - expires_at TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY(namespace, key) - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_memory_records_namespace - ON memory_records(namespace) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_memory_records_expires_at - ON memory_records(expires_at) - """ - ) - try: - conn.execute( - """ - CREATE VIRTUAL TABLE IF NOT EXISTS memory_records_fts - USING fts5(namespace UNINDEXED, key, search_text, tokenize='unicode61') - """ - ) - self._fts_enabled = True - except sqlite3.OperationalError: - self._fts_enabled = False - conn.execute( - """ - CREATE TABLE IF NOT EXISTS memory_embeddings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - namespace TEXT NOT NULL, - key TEXT NOT NULL, - provider_id TEXT NOT NULL, - embedding_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(namespace, key, provider_id) - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_memory_embeddings_provider - ON memory_embeddings(provider_id, namespace) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS memory_vector_state ( - provider_id TEXT PRIMARY KEY, - dirty INTEGER NOT NULL DEFAULT 1, - updated_at TEXT NOT NULL - ) - """ - ) - conn.commit() - self._initialized = True - - def _sync_fts_row_locked( - self, - conn: sqlite3.Connection, - *, - namespace: str, - key: str, - search_text: str, - ) -> None: - if not self._fts_enabled: - return - conn.execute( - "DELETE FROM memory_records_fts WHERE namespace = ? AND key = ?", - (namespace, key), - ) - conn.execute( - """ - INSERT INTO memory_records_fts(namespace, key, search_text) - VALUES(?, ?, ?) - """, - (namespace, key, search_text), - ) - - def _mark_vector_dirty_locked( - self, - conn: sqlite3.Connection, - provider_id: str, - ) -> None: - conn.execute( - """ - INSERT INTO memory_vector_state(provider_id, dirty, updated_at) - VALUES(?, 1, ?) - ON CONFLICT(provider_id) DO UPDATE SET - dirty = 1, - updated_at = excluded.updated_at - """, - (provider_id, _utcnow().isoformat()), - ) - self._vector_indexes.pop(provider_id, None) - self._vector_fallbacks.pop(provider_id, None) - - @staticmethod - def _combined_score( - *, - mode: str, - keyword_score: float, - vector_score: float, - ) -> float: - if mode == "keyword": - return keyword_score - if mode == "vector": - return vector_score - if keyword_score > 0 and vector_score > 0: - return min(1.0, 0.65 * vector_score + 0.35 * keyword_score + 0.05) - if vector_score > 0: - return min(1.0, vector_score) - return min(1.0, keyword_score) - - @staticmethod - def _load_stored_json(raw_value: Any) -> dict[str, Any]: - if isinstance(raw_value, dict): - return dict(raw_value) - if isinstance(raw_value, str): - decoded = json.loads(raw_value) - return dict(decoded) if isinstance(decoded, dict) else {} - return {} - - @staticmethod - def _load_embedding_json(raw_value: Any) -> list[float]: - if isinstance(raw_value, list): - return [float(item) for item in raw_value] - if isinstance(raw_value, str): - decoded = json.loads(raw_value) - if isinstance(decoded, list): - return [float(item) for item in decoded] - return [] - - @staticmethod - def _stored_record_from_row(row: Any) -> _StoredRecord: - return _StoredRecord( - namespace=normalize_memory_namespace(row[0]), - key=str(row[1]), - stored=PluginMemoryBackend._load_stored_json(row[2]), - search_text=str(row[3]), - updated_at=str(row[4]), - ) - - @staticmethod - def _namespace_where( - namespace: str | None, - *, - include_descendants: bool, - alias: str | None = None, - ) -> tuple[str, tuple[Any, ...]]: - column = f"{alias}.namespace" if alias else "namespace" - if namespace is None: - return "1 = 1", () - normalized_namespace = normalize_memory_namespace(namespace) - if not normalized_namespace: - if include_descendants: - return "1 = 1", () - return f"{column} = ''", () - if include_descendants: - escaped_namespace = _escape_like_value(normalized_namespace) - return ( - f"({column} = ? OR {column} LIKE ? ESCAPE '\\')", - (normalized_namespace, f"{escaped_namespace}/%"), - ) - return f"{column} = ?", (normalized_namespace,) - - @staticmethod - def _fts_query(query: str) -> str | None: - stripped = str(query).strip() - if not stripped: - return None - terms = [ - item for item in re.findall(r"\w+", stripped, flags=re.UNICODE) if item - ] - if not terms: - return None - escaped_terms = [term.replace('"', '""') for term in terms[:8]] - return " OR ".join(f'"{term}"' for term in escaped_terms) - - @staticmethod - def _safe_filename(value: str) -> str: - return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value)).strip("._") or "default" - - @staticmethod - def _import_faiss() -> Any: - # FAISS often ships without stable type stubs, so keep the lazy import - # boundary explicitly dynamic to avoid false-positive Pylance errors. - import faiss - - return cast(Any, faiss) - - @staticmethod - def _import_numpy(): - import numpy - - return numpy - - @classmethod - def _faiss_available(cls) -> bool: - try: - faiss = cls._import_faiss() - cls._import_numpy() - except Exception: - return False - required_attrs = ( - "IndexFlatIP", - "IndexIDMap2", - "read_index", - "write_index", - ) - return all(hasattr(faiss, attr) for attr in required_attrs) - - def _vector_backend_label(self) -> str: - return "faiss" if self._faiss_available() else "exact" - - -async def _maybe_await(value: Any) -> Any: - if asyncio.iscoroutine(value) or isinstance(value, asyncio.Future): - return await value - return value - - -def extend_memory_namespace( - base_namespace: str | None, - extra_namespace: str | None, -) -> str: - """Join a base namespace with a relative namespace override.""" - - return join_memory_namespace(base_namespace, extra_namespace) diff --git a/astrbot-sdk/src/astrbot_sdk/_plugin_logger.py b/astrbot-sdk/src/astrbot_sdk/_plugin_logger.py deleted file mode 100644 index 5d2a3d9b1..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_plugin_logger.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._internal.plugin_logger import PluginLogEntry, PluginLogger - -__all__ = ["PluginLogEntry", "PluginLogger"] diff --git a/astrbot-sdk/src/astrbot_sdk/_star_runtime.py b/astrbot-sdk/src/astrbot_sdk/_star_runtime.py deleted file mode 100644 index d6d9fe215..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_star_runtime.py +++ /dev/null @@ -1,13 +0,0 @@ -from ._internal.star_runtime import ( - bind_star_runtime, - current_runtime_context, - current_star_context, - current_star_instance, -) - -__all__ = [ - "bind_star_runtime", - "current_runtime_context", - "current_star_context", - "current_star_instance", -] diff --git a/astrbot-sdk/src/astrbot_sdk/_testing_support.py b/astrbot-sdk/src/astrbot_sdk/_testing_support.py deleted file mode 100644 index 1e945e8e0..000000000 --- a/astrbot-sdk/src/astrbot_sdk/_testing_support.py +++ /dev/null @@ -1,25 +0,0 @@ -from ._internal.testing_support import ( - InMemoryDB, - InMemoryMemory, - MockCapabilityRouter, - MockContext, - MockLLMClient, - MockMessageEvent, - MockPeer, - MockPlatformClient, - RecordedSend, - StdoutPlatformSink, -) - -__all__ = [ - "InMemoryDB", - "InMemoryMemory", - "MockCapabilityRouter", - "MockContext", - "MockLLMClient", - "MockMessageEvent", - "MockPeer", - "MockPlatformClient", - "RecordedSend", - "StdoutPlatformSink", -] diff --git a/astrbot-sdk/src/astrbot_sdk/cli.py b/astrbot-sdk/src/astrbot_sdk/cli.py deleted file mode 100644 index 9704b2390..000000000 --- a/astrbot-sdk/src/astrbot_sdk/cli.py +++ /dev/null @@ -1,1116 +0,0 @@ -"""AstrBot SDK 的命令行入口。 - -本模块提供 astrbot-sdk 命令行工具的所有子命令,包括: -- init: 创建新插件骨架,生成 plugin.yaml、main.py、README.md 等模板文件 -- validate: 校验插件清单、导入路径和 handler 发现是否正常 -- build: 将插件打包为 .zip 发布包 -- dev: 本地开发模式,支持 --local/--watch/--interactive 等调试选项 -- run: 启动插件主管进程(supervisor),通过 stdio 与 AstrBot 核心通信 -- worker: 内部命令,由 supervisor 调用以启动单个插件工作进程 - -错误处理: -所有 CLI 异常都会被分类并返回标准化的退出码和错误提示, -便于 CI/CD 集成和用户快速定位问题。 -""" - -from __future__ import annotations - -import asyncio -import os -import re -import sys -import typing -import zipfile -from collections.abc import Coroutine -from dataclasses import dataclass, field -from pathlib import Path -from textwrap import dedent -from typing import Any - -import click -from loguru import logger - -from .errors import AstrBotError -from .runtime.bootstrap import run_plugin_worker, run_supervisor, run_websocket_server -from .runtime.loader import load_plugin, load_plugin_spec, validate_plugin_spec - -EXIT_OK = 0 -EXIT_UNEXPECTED = 1 -EXIT_USAGE = 2 -EXIT_PLUGIN_LOAD = 3 -EXIT_RUNTIME = 4 -EXIT_PLUGIN_EXECUTION = 5 -BUILD_EXCLUDED_DIRS = { - ".git", - ".idea", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - ".venv", - "__pycache__", - "dist", -} -BUILD_EXCLUDED_FILES = { - ".astrbot-worker-state.json", -} -WATCH_POLL_INTERVAL_SECONDS = 0.5 - - -class _CliPluginValidationError(RuntimeError): - """CLI 侧的插件结构或打包校验失败。""" - - -class _CliPluginLoadError(RuntimeError): - """CLI 侧的本地开发插件加载失败。""" - - -class _CliPluginExecutionError(RuntimeError): - """CLI 侧的本地开发插件执行失败。""" - - -@dataclass(slots=True) -class _PluginTreeWatcher: - plugin_dir: Path - snapshot: dict[str, tuple[int, int]] = field(init=False, default_factory=dict) - - def __post_init__(self) -> None: - self.snapshot = _snapshot_watch_files(self.plugin_dir) - - def poll_changes(self) -> list[str]: - current = _snapshot_watch_files(self.plugin_dir) - changed = sorted( - path - for path in set(self.snapshot) | set(current) - if self.snapshot.get(path) != current.get(path) - ) - self.snapshot = current - return changed - - -def setup_logger(verbose: bool = False) -> None: - """初始化 CLI 使用的日志配置。""" - logger.remove() - logger.add( - sys.stderr, - format="{time:HH:mm:ss} | {level: <8} | {message}", - level="DEBUG" if verbose else "INFO", - colorize=True, - ) - - -def _resolve_protocol_stdout( - protocol_stdout: str | None, -) -> tuple[typing.TextIO, typing.TextIO | None]: - configured = str(protocol_stdout).strip() if protocol_stdout is not None else "" - if not configured: - stdout = sys.stdout - if callable(getattr(stdout, "isatty", None)) and stdout.isatty(): - opened_stdout = open(os.devnull, "w", encoding="utf-8") - return opened_stdout, opened_stdout - return stdout, None - if configured.lower() == "console": - return sys.stdout, None - output_path = os.devnull if configured.lower() == "silent" else configured - opened_stdout = open(output_path, "w", encoding="utf-8") - return opened_stdout, opened_stdout - - -def _run_async_entrypoint( - entrypoint: Coroutine[Any, Any, object], - *, - log_message: str, - log_level: str = "info", - context: dict[str, Any] | None = None, -) -> None: - log_method = getattr(logger, log_level) - log_method(log_message) - try: - asyncio.run(entrypoint) - except (click.Abort, KeyboardInterrupt): - click.echo("\n创建插件已优雅地中断。", err=True) - raise SystemExit(130) - except Exception as exc: - exit_code, error_code, hint = _classify_cli_exception(exc) - docs_url = exc.docs_url if isinstance(exc, AstrBotError) else "" - details = exc.details if isinstance(exc, AstrBotError) else None - _render_cli_error( - error_code=error_code, - message=str(exc), - hint=hint, - docs_url=docs_url, - details=details, - context=context, - ) - if exit_code == EXIT_UNEXPECTED: - logger.exception("CLI 异常退出") - raise SystemExit(exit_code) from exc - - -def _run_sync_entrypoint( - entrypoint: typing.Callable[[], object], - *, - log_message: str, - log_level: str = "info", - context: dict[str, Any] | None = None, -) -> None: - log_method = getattr(logger, log_level) - log_method(log_message) - try: - entrypoint() - except (click.Abort, KeyboardInterrupt): - click.echo("\n创建插件已优雅地中断。", err=True) - raise SystemExit(130) - except Exception as exc: - exit_code, error_code, hint = _classify_cli_exception(exc) - docs_url = exc.docs_url if isinstance(exc, AstrBotError) else "" - details = exc.details if isinstance(exc, AstrBotError) else None - _render_cli_error( - error_code=error_code, - message=str(exc), - hint=hint, - docs_url=docs_url, - details=details, - context=context, - ) - if exit_code == EXIT_UNEXPECTED: - logger.exception("CLI 异常退出") - raise SystemExit(exit_code) from exc - - -def _classify_cli_exception(exc: Exception) -> tuple[int, str, str]: - if isinstance(exc, AstrBotError): - return ( - EXIT_RUNTIME, - exc.code, - exc.hint or "请检查本地 mock core 与插件调用参数", - ) - if isinstance( - exc, - ( - _CliPluginValidationError, - _CliPluginLoadError, - FileNotFoundError, - ImportError, - ModuleNotFoundError, - ), - ): - return ( - EXIT_PLUGIN_LOAD, - "plugin_load_error", - "请检查插件目录、plugin.yaml、requirements.txt(如有)和导入路径", - ) - if isinstance(exc, LookupError): - return ( - EXIT_RUNTIME, - "dispatch_error", - "请检查 handler 或 capability 是否已正确注册", - ) - if isinstance(exc, _CliPluginExecutionError): - return ( - EXIT_PLUGIN_EXECUTION, - "plugin_execution_error", - "请检查插件生命周期、handler 或 capability 的实现", - ) - return ( - EXIT_UNEXPECTED, - "unexpected_error", - "请查看详细日志,必要时使用 --verbose 重试", - ) - - -def _render_cli_error( - *, - error_code: str, - message: str, - hint: str = "", - docs_url: str = "", - details: dict[str, Any] | None = None, - context: dict[str, Any] | None = None, -) -> None: - click.echo(f"Error[{error_code}]: {message}", err=True) - if hint: - click.echo(f"Suggestion: {hint}", err=True) - if docs_url: - click.echo(f"Docs: {docs_url}", err=True) - if details: - click.echo(f"Details: {details}", err=True) - if not context: - return - for key, value in context.items(): - click.echo(f"{key}: {value}", err=True) - - -def _render_nonfatal_dev_error( - exc: Exception, - *, - context: dict[str, Any] | None = None, -) -> None: - exit_code, error_code, hint = _classify_cli_exception(exc) - _render_cli_error( - error_code=error_code, - message=str(exc), - hint=hint, - context=context, - ) - if exit_code == EXIT_UNEXPECTED: - logger.exception("watch 模式收到未分类异常") - - -def _iter_watch_files(plugin_dir: Path) -> typing.Iterator[Path]: - root = plugin_dir.resolve() - for path in sorted(root.rglob("*")): - if path.is_dir(): - continue - relative = path.relative_to(root) - if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]): - continue - if relative.name in BUILD_EXCLUDED_FILES: - continue - if path.suffix in {".pyc", ".pyo"}: - continue - yield path - - -def _snapshot_watch_files(plugin_dir: Path) -> dict[str, tuple[int, int]]: - root = plugin_dir.resolve() - snapshot: dict[str, tuple[int, int]] = {} - for path in _iter_watch_files(root): - try: - stat = path.stat() - except FileNotFoundError: - continue - snapshot[path.relative_to(root).as_posix()] = ( - stat.st_mtime_ns, - stat.st_size, - ) - return snapshot - - -def _format_watch_changes(changes: list[str], *, limit: int = 5) -> str: - if not changes: - return "未知文件" - preview = changes[:limit] - text = ", ".join(preview) - if len(changes) > limit: - text += f" 等 {len(changes)} 个文件" - return text - - -class _ReloadableLocalDevRunner: - def __init__( - self, - *, - plugin_dir: Path, - state: dict[str, Any], - plugin_load_error: type[Exception], - plugin_execution_error: type[Exception], - plugin_harness, - stdout_platform_sink, - ) -> None: - self.plugin_dir = plugin_dir - self.state = state - self._plugin_load_error = plugin_load_error - self._plugin_execution_error = plugin_execution_error - self._plugin_harness = plugin_harness - self._stdout_platform_sink = stdout_platform_sink - self._harness = None - self._lock = asyncio.Lock() - - async def close(self) -> None: - async with self._lock: - await self._stop_harness() - - async def reload(self) -> bool: - async with self._lock: - await self._stop_harness() - harness = self._plugin_harness.from_plugin_dir( - self.plugin_dir, - session_id=str(self.state["session_id"]), - user_id=str(self.state["user_id"]), - platform=str(self.state["platform"]), - group_id=typing.cast(str | None, self.state["group_id"]), - event_type=str(self.state["event_type"]), - platform_sink=self._stdout_platform_sink(stream=sys.stdout), - ) - try: - await harness.start() - except self._plugin_load_error as exc: - _render_nonfatal_dev_error( - _CliPluginLoadError(str(exc)), - context={"plugin_dir": self.plugin_dir}, - ) - return False - except self._plugin_execution_error as exc: - _render_nonfatal_dev_error( - _CliPluginExecutionError(str(exc)), - context={"plugin_dir": self.plugin_dir}, - ) - return False - self._harness = harness - return True - - async def dispatch_text(self, text: str) -> bool: - async with self._lock: - if self._harness is None: - click.echo("当前插件未成功加载,等待下一次文件变更后重试。") - return False - try: - await self._harness.dispatch_text( - text, - session_id=str(self.state["session_id"]), - user_id=str(self.state["user_id"]), - platform=str(self.state["platform"]), - group_id=typing.cast(str | None, self.state["group_id"]), - event_type=str(self.state["event_type"]), - ) - except (self._plugin_load_error, self._plugin_execution_error) as exc: - _render_nonfatal_dev_error( - _CliPluginExecutionError(str(exc)), - context={"plugin_dir": self.plugin_dir}, - ) - return False - except Exception as exc: - _render_nonfatal_dev_error( - exc, - context={"plugin_dir": self.plugin_dir}, - ) - return False - return True - - async def _stop_harness(self) -> None: - if self._harness is None: - return - try: - await self._harness.stop() - finally: - self._harness = None - - -async def _run_local_dev_watch( - *, - runner: _ReloadableLocalDevRunner, - event_text: str | None, - interactive: bool, - watch_poll_interval: float, - max_watch_reloads: int | None = None, -) -> None: - watcher = _PluginTreeWatcher(runner.plugin_dir) - reload_count = 0 - - async def reload_and_maybe_rerun(*, announce: str | None) -> None: - if announce: - click.echo(announce) - if not await runner.reload(): - return - if event_text is not None: - await runner.dispatch_text(event_text) - - async def watch_loop(stop_event: asyncio.Event) -> None: - nonlocal reload_count - while not stop_event.is_set(): - await asyncio.sleep(watch_poll_interval) - changes = watcher.poll_changes() - if not changes: - continue - await reload_and_maybe_rerun( - announce=( - f"检测到文件变更,重新加载插件:{_format_watch_changes(changes)}" - ) - ) - reload_count += 1 - if max_watch_reloads is not None and reload_count >= max_watch_reloads: - stop_event.set() - return - - stop_event = asyncio.Event() - watch_task: asyncio.Task[None] | None = None - try: - await reload_and_maybe_rerun( - announce=( - "watch 模式已启动,监听插件目录变更。" - if event_text is not None - else "watch 模式已启动,监听插件目录变更并按需热重载。" - ) - ) - if max_watch_reloads == 0: - return - watch_task = asyncio.create_task(watch_loop(stop_event)) - if interactive: - click.echo( - "本地交互模式已启动。可用命令:/session /user /platform /group /private /event /exit" - ) - while not stop_event.is_set(): - line = await asyncio.to_thread(sys.stdin.readline) - if not line: - break - text = line.strip() - if not text: - continue - if _handle_dev_meta_command(text, runner.state): - if text in {"/exit", "/quit"}: - break - continue - await runner.dispatch_text(text) - stop_event.set() - return - await stop_event.wait() - finally: - stop_event.set() - if watch_task is not None: - watch_task.cancel() - try: - await watch_task - except asyncio.CancelledError: - pass - await runner.close() - - -async def _run_local_dev( - *, - plugin_dir: Path, - event_text: str | None, - interactive: bool, - watch: bool, - session_id: str, - user_id: str, - platform: str, - group_id: str | None, - event_type: str, - watch_poll_interval: float = WATCH_POLL_INTERVAL_SECONDS, - max_watch_reloads: int | None = None, -) -> None: - from .testing import ( - PluginHarness, - StdoutPlatformSink, - _PluginExecutionError, - _PluginLoadError, - ) - - state = { - "session_id": session_id, - "user_id": user_id, - "platform": platform, - "group_id": group_id, - "event_type": event_type, - } - if watch: - runner = _ReloadableLocalDevRunner( - plugin_dir=plugin_dir, - state=state, - plugin_load_error=_PluginLoadError, - plugin_execution_error=_PluginExecutionError, - plugin_harness=PluginHarness, - stdout_platform_sink=StdoutPlatformSink, - ) - await _run_local_dev_watch( - runner=runner, - event_text=event_text, - interactive=interactive, - watch_poll_interval=watch_poll_interval, - max_watch_reloads=max_watch_reloads, - ) - return - - sink = StdoutPlatformSink(stream=sys.stdout) - harness = PluginHarness.from_plugin_dir( - plugin_dir, - session_id=session_id, - user_id=user_id, - platform=platform, - group_id=group_id, - event_type=event_type, - platform_sink=sink, - ) - try: - async with harness: - if interactive: - click.echo( - "本地交互模式已启动。可用命令:/session /user /platform /group /private /event /exit" - ) - while True: - line = await asyncio.to_thread(sys.stdin.readline) - if not line: - break - text = line.strip() - if not text: - continue - if _handle_dev_meta_command(text, state): - if text in {"/exit", "/quit"}: - break - continue - await harness.dispatch_text( - text, - session_id=str(state["session_id"]), - user_id=str(state["user_id"]), - platform=str(state["platform"]), - group_id=typing.cast(str | None, state["group_id"]), - event_type=str(state["event_type"]), - ) - return - assert event_text is not None - await harness.dispatch_text( - event_text, - session_id=session_id, - user_id=user_id, - platform=platform, - group_id=group_id, - event_type=event_type, - ) - except _PluginLoadError as exc: - raise _CliPluginLoadError(str(exc)) from exc - except _PluginExecutionError as exc: - raise _CliPluginExecutionError(str(exc)) from exc - - -def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool: - if command in {"/exit", "/quit"}: - return True - if command.startswith("/session "): - state["session_id"] = command.split(" ", 1)[1].strip() - click.echo(f"切换 session_id -> {state['session_id']}") - return True - if command.startswith("/user "): - state["user_id"] = command.split(" ", 1)[1].strip() - click.echo(f"切换 user_id -> {state['user_id']}") - return True - if command.startswith("/platform "): - state["platform"] = command.split(" ", 1)[1].strip() - click.echo(f"切换 platform -> {state['platform']}") - return True - if command.startswith("/group "): - state["group_id"] = command.split(" ", 1)[1].strip() - click.echo(f"切换 group_id -> {state['group_id']}") - return True - if command == "/private": - state["group_id"] = None - click.echo("已切换为私聊上下文") - return True - if command.startswith("/event "): - state["event_type"] = command.split(" ", 1)[1].strip() - click.echo(f"切换 event_type -> {state['event_type']}") - return True - return False - - -def _slugify_plugin_name(value: str) -> str: - slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower() - return slug or "my_plugin" - - -def _normalize_plugin_name(value: str) -> str: - normalized = _slugify_plugin_name(value) - if normalized.startswith("astrbot_plugin_"): - return normalized - normalized = normalized.removeprefix("astrbot_plugin") - normalized = normalized.strip("_") - suffix = normalized or "my_plugin" - return f"astrbot_plugin_{suffix}" - - -def _class_name_for_plugin(value: str) -> str: - parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part] - if not parts: - return "MyPlugin" - return "".join(part[:1].upper() + part[1:] for part in parts) - - -def _sanitize_build_part(value: str) -> str: - sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("._-") - return sanitized or "artifact" - - -def _render_init_plugin_yaml( - *, - plugin_name: str, - display_name: str, - desc: str, - author: str, - version: str, -) -> str: - python_version = f"{sys.version_info.major}.{sys.version_info.minor}" - class_name = _class_name_for_plugin(plugin_name) - return dedent( - f"""\ - name: {plugin_name} - display_name: {display_name} - desc: {desc} - author: {author} - version: {version} - runtime: - python: "{python_version}" - components: - - class: main:{class_name} - """ - ) - - -def _render_init_main_py(*, plugin_name: str) -> str: - class_name = _class_name_for_plugin(plugin_name) - return dedent( - f"""\ - from astrbot_sdk import Context, MessageEvent, Star, on_command - - - class {class_name}(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("Hello, World!") - """ - ) - - -def _render_init_readme(*, plugin_name: str) -> str: - return dedent( - f"""\ - # {plugin_name} - - 一个最小可运行的 AstrBot SDK v4 插件。 - - ## 目录结构 - - ``` - . - ├── plugin.yaml - ├── requirements.txt - ├── main.py - └── tests - └── test_plugin.py - ``` - - ## 本地开发 - - ```bash - astrbot-sdk validate - astrbot-sdk dev --local --event-text hello - astrbot-sdk dev --local --watch --event-text hello - ``` - - ## 运行测试 - - ```bash - python -m pytest tests/test_plugin.py -v - ``` - """ - ) - - -def _render_init_test_py(*, plugin_name: str) -> str: - class_name = _class_name_for_plugin(plugin_name) - return dedent( - f"""\ - from pathlib import Path - - import pytest - - from astrbot_sdk.testing import MockContext, MockMessageEvent, PluginHarness - from main import {class_name} - - - @pytest.mark.asyncio - async def test_hello_handler(): - plugin = {class_name}() - ctx = MockContext( - plugin_id="{plugin_name}", - plugin_metadata={{"display_name": "{class_name}"}}, - ) - event = MockMessageEvent(text="/hello", context=ctx) - - await plugin.hello(event, ctx) - - assert event.replies == ["Hello, World!"] - ctx.platform.assert_sent("Hello, World!") - - - @pytest.mark.asyncio - async def test_hello_dispatch(): - plugin_dir = Path(__file__).resolve().parents[1] - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - records = await harness.dispatch_text("hello") - - assert any(record.text == "Hello, World!" for record in records) - """ - ) - - -def _ensure_plugin_dir_exists(plugin_dir: Path) -> Path: - resolved = plugin_dir.resolve() - if not resolved.exists() or not resolved.is_dir(): - raise _CliPluginValidationError(f"插件目录不存在:{plugin_dir}") - return resolved - - -def _resolve_dev_plugin_dir(plugin_dir: Path | None) -> Path: - if plugin_dir is not None: - return plugin_dir - current_dir = Path.cwd() - if (current_dir / "plugin.yaml").exists(): - return Path(".") - raise click.BadParameter( - "未提供 --plugin-dir,且当前目录未找到 plugin.yaml", - param_hint="--plugin-dir", - ) - - -def _load_validated_plugin(plugin_dir: Path) -> tuple[Any, Any]: - resolved_dir = _ensure_plugin_dir_exists(plugin_dir) - plugin = load_plugin_spec(resolved_dir) - try: - validate_plugin_spec(plugin) - except ValueError as exc: - raise _CliPluginValidationError(str(exc)) from exc - - loaded = load_plugin(plugin) - if not loaded.instances: - raise _CliPluginValidationError( - "未找到可加载的组件,请检查 plugin.yaml 中的 components" - ) - return plugin, loaded - - -def _build_kind(plugin: Any) -> str: - return ( - "legacy-main" - if bool(plugin.manifest_data.get("__legacy_main__")) - else "plugin-yaml" - ) - - -def _path_is_within(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - except ValueError: - return False - return True - - -def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]: - files: list[Path] = [] - for path in sorted(plugin_dir.rglob("*")): - if path.is_dir(): - continue - if _path_is_within(path, output_dir): - continue - relative = path.relative_to(plugin_dir) - if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]): - continue - if relative.name in BUILD_EXCLUDED_FILES: - continue - if path.suffix in {".pyc", ".pyo"}: - continue - files.append(path) - return files - - -def _prompt_nonempty_text(prompt: str) -> str: - while True: - value = click.prompt(prompt, type=str, default="", show_default=False).strip() - if value: - return value - click.echo("该字段不能为空,请重新输入。") - - -def _collect_init_metadata(name: str | None) -> tuple[str, str, str, str]: - if name is not None: - return name, "", "", "1.0.0" - - plugin_name = _prompt_nonempty_text("插件名字") - author = click.prompt("作者", type=str, default="", show_default=False).strip() - desc = click.prompt("描述", type=str, default="", show_default=False).strip() - version = click.prompt("版本", type=str, default="1.0.0", show_default=True).strip() - return plugin_name, author, desc, version or "1.0.0" - - -def _init_plugin(name: str | None) -> None: - raw_name, author, desc, version = _collect_init_metadata(name) - plugin_name = _normalize_plugin_name(raw_name) - target_dir = Path(plugin_name) - if target_dir.exists(): - raise _CliPluginValidationError(f"目标目录已存在:{target_dir}") - - display_name = raw_name.strip() or plugin_name - target_dir.mkdir(parents=True, exist_ok=False) - (target_dir / "tests").mkdir() - (target_dir / "plugin.yaml").write_text( - _render_init_plugin_yaml( - plugin_name=plugin_name, - display_name=display_name, - desc=desc, - author=author, - version=version, - ), - encoding="utf-8", - ) - (target_dir / "requirements.txt").write_text("", encoding="utf-8") - (target_dir / "main.py").write_text( - _render_init_main_py(plugin_name=plugin_name), - encoding="utf-8", - ) - (target_dir / "README.md").write_text( - _render_init_readme(plugin_name=plugin_name), - encoding="utf-8", - ) - (target_dir / "tests" / "test_plugin.py").write_text( - _render_init_test_py(plugin_name=plugin_name), - encoding="utf-8", - ) - click.echo(f"已创建插件:{target_dir}") - click.echo("后续命令:") - click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}") - click.echo( - f" astrbot-sdk dev --local --plugin-dir {target_dir} --event-text hello" - ) - - -def _validate_plugin(plugin_dir: Path) -> None: - plugin, loaded = _load_validated_plugin(plugin_dir) - click.echo(f"校验通过:{plugin.name}") - click.echo(f"kind: {_build_kind(plugin)}") - click.echo(f"plugin_dir: {plugin.plugin_dir}") - click.echo(f"handlers: {len(loaded.handlers)}") - click.echo(f"capabilities: {len(loaded.capabilities)}") - click.echo(f"instances: {len(loaded.instances)}") - - -def _build_plugin(plugin_dir: Path, output_dir: Path | None) -> None: - plugin, _ = _load_validated_plugin(plugin_dir) - build_dir = (output_dir or (plugin.plugin_dir / "dist")).resolve() - build_dir.mkdir(parents=True, exist_ok=True) - - version = _sanitize_build_part(str(plugin.manifest_data.get("version") or "0.0.0")) - archive_name = f"{_sanitize_build_part(plugin.name)}-{version}.zip" - archive_path = build_dir / archive_name - - with zipfile.ZipFile( - archive_path, - mode="w", - compression=zipfile.ZIP_DEFLATED, - ) as archive: - for path in _iter_build_files(plugin.plugin_dir, build_dir): - archive.write(path, arcname=path.relative_to(plugin.plugin_dir)) - - click.echo(f"构建完成:{archive_path}") - click.echo(f"artifact: {archive_path}") - - -@click.group() -@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output") -@click.pass_context -def cli(ctx, verbose: bool) -> None: - """AstrBot SDK CLI。""" - ctx.ensure_object(dict) - ctx.obj["verbose"] = verbose - setup_logger(verbose) - - -@cli.command() -@click.option( - "--plugins-dir", - default="plugins", - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), - help="Directory containing plugin folders", -) -@click.option( - "--protocol-stdout", - default=None, - type=str, - help="Redirect runtime protocol stdout to console, silent, or a file path", -) -def run(plugins_dir: Path, protocol_stdout: str | None) -> None: - """Start the plugin supervisor over stdio.""" - transport_stdout, opened_stdout = _resolve_protocol_stdout(protocol_stdout) - try: - _run_async_entrypoint( - run_supervisor(plugins_dir=plugins_dir, stdout=transport_stdout), - log_message=f"启动插件主管进程,插件目录:{plugins_dir}", - context={"plugins_dir": plugins_dir}, - ) - finally: - if opened_stdout is not None: - opened_stdout.close() - - -@cli.command() -@click.argument("name", type=str, required=False) -def init(name: str | None) -> None: - """Create a new plugin skeleton in the target directory.""" - _run_sync_entrypoint( - lambda: _init_plugin(name), - log_message=f"创建插件:{name or ''}", - context={"target": name or ""}, - ) - - -@cli.command() -@click.option( - "--plugin-dir", - default=".", - show_default=True, - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), - help="Plugin directory to validate", -) -def validate(plugin_dir: Path) -> None: - """Validate plugin manifest, imports and handler discovery.""" - _run_sync_entrypoint( - lambda: _validate_plugin(plugin_dir), - log_message=f"校验插件目录:{plugin_dir}", - context={"plugin_dir": plugin_dir}, - ) - - -@cli.command() -@click.option( - "--plugin-dir", - default=".", - show_default=True, - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), - help="Plugin directory to package", -) -@click.option( - "--output-dir", - default=None, - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), - help="Directory for the build artifact, defaults to /dist", -) -def build(plugin_dir: Path, output_dir: Path | None) -> None: - """Validate and package a plugin into a zip artifact.""" - _run_sync_entrypoint( - lambda: _build_plugin(plugin_dir, output_dir), - log_message=f"构建插件包:{plugin_dir}", - context={"plugin_dir": plugin_dir, "output_dir": output_dir}, - ) - - -@cli.command() -@click.option( - "--plugin-dir", - required=False, - default=None, - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), - help="Plugin directory to run locally, defaults to current directory when plugin.yaml exists", -) -@click.option("--local", "local_mode", is_flag=True, help="Run against local mock core") -@click.option( - "--standalone", - "standalone_mode", - is_flag=True, - help="Deprecated alias of --local", -) -@click.option("--event-text", type=str, help="Single message text to dispatch") -@click.option("--interactive", is_flag=True, help="Read follow-up messages from stdin") -@click.option( - "--watch", - is_flag=True, - help="Reload the local harness when plugin files change", -) -@click.option("--session-id", default="local-session", show_default=True) -@click.option("--user-id", default="local-user", show_default=True) -@click.option("--platform", "platform_name", default="test", show_default=True) -@click.option("--group-id", default=None) -@click.option("--event-type", default="message", show_default=True) -def dev( - plugin_dir: Path | None, - local_mode: bool, - standalone_mode: bool, - event_text: str | None, - interactive: bool, - watch: bool, - session_id: str, - user_id: str, - platform_name: str, - group_id: str | None, - event_type: str, -) -> None: - """Run a plugin against the local mock core for development.""" - if not (local_mode or standalone_mode): - raise click.BadParameter("当前 dev 只支持 --local/--standalone 模式") - if interactive and event_text: - raise click.BadParameter("--interactive 与 --event-text 不能同时使用") - if not interactive and not event_text: - raise click.BadParameter("请提供 --event-text,或改用 --interactive") - resolved_plugin_dir = _resolve_dev_plugin_dir(plugin_dir) - _run_async_entrypoint( - _run_local_dev( - plugin_dir=resolved_plugin_dir, - event_text=event_text, - interactive=interactive, - watch=watch, - session_id=session_id, - user_id=user_id, - platform=platform_name, - group_id=group_id, - event_type=event_type, - ), - log_message=f"启动本地开发模式:{resolved_plugin_dir}", - context={ - "plugin_dir": resolved_plugin_dir, - "session_id": session_id, - "platform": platform_name, - "event_type": event_type, - }, - ) - - -@cli.command(hidden=True) -@click.option( - "--plugin-dir", - required=False, - type=click.Path(file_okay=False, dir_okay=True, path_type=Path), -) -@click.option( - "--group-metadata", - required=False, - type=click.Path(file_okay=True, dir_okay=False, path_type=Path), -) -@click.option( - "--protocol-stdout", - default=None, - type=str, - help="Redirect runtime protocol stdout to console, silent, or a file path", -) -def worker( - plugin_dir: Path | None, - group_metadata: Path | None, - protocol_stdout: str | None, -) -> None: - """Internal command used by the supervisor to start a worker.""" - if plugin_dir is None and group_metadata is None: - raise click.UsageError("Either --plugin-dir or --group-metadata is required") - if plugin_dir is not None and group_metadata is not None: - raise click.UsageError( - "--plugin-dir and --group-metadata are mutually exclusive" - ) - - target = str(group_metadata or plugin_dir) - transport_stdout, opened_stdout = _resolve_protocol_stdout(protocol_stdout) - if group_metadata is not None: - entrypoint = run_plugin_worker( - group_metadata=group_metadata, - stdout=transport_stdout, - ) - else: - entrypoint = run_plugin_worker( - plugin_dir=plugin_dir, - stdout=transport_stdout, - ) - try: - _run_async_entrypoint( - entrypoint, - log_message=f"启动插件工作进程:{target}", - log_level="debug", - context={"plugin_dir": plugin_dir}, - ) - finally: - if opened_stdout is not None: - opened_stdout.close() - - -@cli.command(hidden=True) -@click.option("--port", default=8765, type=int, help="WebSocket server port") -def websocket(port: int) -> None: - """WebSocket runtime entrypoint kept for standalone bridge scenarios.""" - _run_async_entrypoint( - run_websocket_server(port=port), - log_message=f"启动 WebSocket 服务器,端口:{port}", - context={"port": port}, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/clients/__init__.py b/astrbot-sdk/src/astrbot_sdk/clients/__init__.py deleted file mode 100644 index a62e2d119..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Native v4 capability clients. - -These clients provide the narrow, typed surface exposed by `Context` for -calling remote capabilities. They handle capability names, payload shaping, -and result decoding, without exposing protocol or transport details. - -Migration shims and higher-level orchestration stay outside these native -capability clients so `Context` keeps a narrow, stable surface. - -当前公开客户端: - - LLMClient: 文本/结构化/流式 LLM 调用 - - MemoryClient: 记忆搜索、保存、读取、删除 - - DBClient: 键值存储 get/set/delete/list - - FileServiceClient: 文件令牌注册与解析 - - PlatformClient: 平台消息发送与成员查询 - - ProviderClient: Provider 元信息与专用 provider proxy - - PersonaManagerClient: 人格管理 - - ConversationManagerClient: 对话管理 - - KnowledgeBaseManagerClient: 知识库管理 - - HTTPClient: Web API 注册 - - MetadataClient: 插件元数据查询 - - SkillClient: 运行时注册插件 skill -""" - -from .db import DBClient -from .files import FileRegistration, FileServiceClient -from .http import HTTPClient -from .llm import ChatMessage, LLMClient, LLMResponse -from .managers import ( - ConversationCreateParams, - ConversationManagerClient, - ConversationRecord, - ConversationUpdateParams, - KnowledgeBaseCreateParams, - KnowledgeBaseManagerClient, - KnowledgeBaseRecord, - MessageHistoryManagerClient, - MessageHistoryPage, - MessageHistoryRecord, - MessageHistorySender, - PersonaCreateParams, - PersonaManagerClient, - PersonaRecord, - PersonaUpdateParams, -) -from .mcp import MCPManagerClient, MCPServerRecord, MCPServerScope, MCPSession -from .memory import MemoryClient -from .metadata import MetadataClient, PluginMetadata, StarMetadata -from .platform import PlatformClient, PlatformError, PlatformStats, PlatformStatus -from .provider import ( - ManagedProviderRecord, - ProviderChangeEvent, - ProviderClient, - ProviderManagerClient, -) -from .registry import HandlerMetadata, RegistryClient -from .session import SessionPluginManager, SessionServiceManager -from .skills import SkillClient, SkillRegistration - -__all__ = [ - "ChatMessage", - "ConversationCreateParams", - "ConversationManagerClient", - "ConversationRecord", - "ConversationUpdateParams", - "DBClient", - "FileRegistration", - "FileServiceClient", - "HTTPClient", - "KnowledgeBaseCreateParams", - "KnowledgeBaseManagerClient", - "KnowledgeBaseRecord", - "MessageHistoryManagerClient", - "MessageHistoryPage", - "MessageHistoryRecord", - "MessageHistorySender", - "LLMClient", - "LLMResponse", - "MCPManagerClient", - "MCPSession", - "MCPServerRecord", - "MCPServerScope", - "MemoryClient", - "ManagedProviderRecord", - "MetadataClient", - "PlatformClient", - "PlatformError", - "PlatformStats", - "PlatformStatus", - "PersonaCreateParams", - "PersonaManagerClient", - "PersonaRecord", - "PersonaUpdateParams", - "ProviderChangeEvent", - "ProviderClient", - "ProviderManagerClient", - "PluginMetadata", - "StarMetadata", - "HandlerMetadata", - "RegistryClient", - "SessionPluginManager", - "SessionServiceManager", - "SkillClient", - "SkillRegistration", -] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/_proxy.py b/astrbot-sdk/src/astrbot_sdk/clients/_proxy.py deleted file mode 100644 index da2bca6da..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/_proxy.py +++ /dev/null @@ -1,188 +0,0 @@ -"""能力代理模块。 - -提供 CapabilityProxy 类,作为客户端与 Peer 之间的中间层,负责: -- 检查远程能力是否可用 -- 验证流式调用支持 -- 统一封装 invoke 和 invoke_stream 调用 - -设计说明: - CapabilityProxy 是新版架构的核心组件。每个专用客户端 (LLMClient, DBClient 等) - 都通过 CapabilityProxy 与远程通信,并在发起调用时绑定当前插件身份, - 让运行时把调用者信息放进协议层而不是业务 payload。 - -使用示例: - proxy = CapabilityProxy(peer) - - # 普通调用 - result = await proxy.call("llm.chat", {"prompt": "hello"}) - - # 流式调用 - async for delta in proxy.stream("llm.stream_chat", {"prompt": "hello"}): - print(delta["text"]) -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Mapping -from typing import Any, Protocol - -from .._internal.invocation_context import caller_plugin_scope -from ..errors import AstrBotError - - -class _CapabilityDescriptorLike(Protocol): - supports_stream: bool | None - - -class _CapabilityPeerLike(Protocol): - remote_capability_map: Mapping[str, _CapabilityDescriptorLike] - remote_peer: Any | None - - async def invoke( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, Any]: ... - - async def invoke_stream( - self, - capability: str, - payload: dict[str, Any], - *, - request_id: str | None = None, - ) -> AsyncIterator[Any]: ... - - -class CapabilityProxy: - """能力代理类,封装 Peer 的能力调用接口。 - - 负责在调用前验证能力可用性和流式支持,提供统一的 call/stream 接口。 - - Attributes: - _peer: 底层 Peer 实例,负责实际的 RPC 通信 - """ - - def __init__( - self, - peer: _CapabilityPeerLike, - caller_plugin_id: str | None = None, - request_scope_id: str | None = None, - ) -> None: - """初始化能力代理。 - - Args: - peer: Peer 实例,提供 remote_capability_map 和 invoke/invoke_stream 方法 - """ - self._peer = peer - self._caller_plugin_id = caller_plugin_id - self._request_scope_id = request_scope_id - - def _get_descriptor(self, name: str): - """获取能力描述符。 - - Args: - name: 能力名称,如 "llm.chat" - - Returns: - 能力描述符,若不存在则返回 None - """ - capability_map = getattr(self._peer, "remote_capability_map", {}) - if not isinstance(capability_map, Mapping): - return None - return capability_map.get(name) - - def _remote_initialized(self) -> bool: - peer_attrs = getattr(self._peer, "__dict__", None) - if not isinstance(peer_attrs, dict): - return False - - # Avoid getattr() here: MagicMock synthesizes truthy child attributes and - # makes an uninitialized peer look ready. - remote_peer = peer_attrs.get("remote_peer") - capability_map = peer_attrs.get("remote_capability_map") - return bool(remote_peer) or ( - isinstance(capability_map, Mapping) and bool(capability_map) - ) - - def _ensure_available(self, name: str, *, stream: bool) -> None: - """确保能力可用且支持指定的调用模式。 - - Args: - name: 能力名称 - stream: 是否需要流式支持 - - Raises: - AstrBotError: 能力不存在或流式不支持 - """ - descriptor = self._get_descriptor(name) - if descriptor is None: - if self._remote_initialized(): - raise AstrBotError.capability_not_found(name) - return - if stream and not descriptor.supports_stream: - raise AstrBotError.invalid_input(f"{name} 不支持 stream=true") - - def _prepare_payload(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: - if ( - not isinstance(self._request_scope_id, str) - or not self._request_scope_id - or not name.startswith("system.event.") - ): - return payload - scoped_payload = dict(payload) - scoped_payload.setdefault("_request_scope_id", self._request_scope_id) - return scoped_payload - - async def call(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: - """执行普通能力调用(非流式)。 - - Args: - name: 能力名称,如 "llm.chat", "db.get" - payload: 调用参数字典 - - Returns: - 调用结果字典 - - Raises: - AstrBotError: 能力不存在或调用失败 - - 示例: - result = await proxy.call("llm.chat", {"prompt": "hello"}) - print(result["text"]) - """ - self._ensure_available(name, stream=False) - prepared_payload = self._prepare_payload(name, payload) - with caller_plugin_scope(self._caller_plugin_id): - return await self._peer.invoke(name, prepared_payload, stream=False) - - async def stream( - self, - name: str, - payload: dict[str, Any], - ) -> AsyncIterator[dict[str, Any]]: - """执行流式能力调用。 - - Args: - name: 能力名称,如 "llm.stream_chat" - payload: 调用参数字典 - - Yields: - 每个增量数据块(phase="delta" 时的 data 字段) - - Raises: - AstrBotError: 能力不存在或不支持流式 - - 示例: - async for delta in proxy.stream("llm.stream_chat", {"prompt": "hello"}): - print(delta["text"], end="") - """ - self._ensure_available(name, stream=True) - prepared_payload = self._prepare_payload(name, payload) - with caller_plugin_scope(self._caller_plugin_id): - event_stream = await self._peer.invoke_stream(name, prepared_payload) - async for event in event_stream: - if event.phase == "delta": - yield event.data diff --git a/astrbot-sdk/src/astrbot_sdk/clients/db.py b/astrbot-sdk/src/astrbot_sdk/clients/db.py deleted file mode 100644 index bf2783490..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/db.py +++ /dev/null @@ -1,161 +0,0 @@ -"""数据库客户端模块。 - -提供键值存储能力,用于持久化插件数据。 - -功能说明: - - 数据永久存储,除非用户显式删除 - - 值类型支持任意 JSON 数据 - - 支持前缀查询键列表 - - 支持批量读写 - - 支持订阅变更事件 -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Mapping, Sequence -from typing import Any - -from ._proxy import CapabilityProxy - - -class DBClient: - """键值数据库客户端。 - - 提供插件数据的持久化存储能力,数据永久保存直到显式删除。 - - Attributes: - _proxy: CapabilityProxy 实例,用于远程能力调用 - """ - - def __init__(self, proxy: CapabilityProxy) -> None: - """初始化数据库客户端。 - - Args: - proxy: CapabilityProxy 实例 - """ - self._proxy = proxy - - async def get(self, key: str) -> Any | None: - """获取指定键的值。 - - Args: - key: 数据键名 - - Returns: - 存储的值,若键不存在则返回 None - - 示例: - data = await ctx.db.get("user_settings") - if data: - print(data["theme"]) - """ - output = await self._proxy.call("db.get", {"key": key}) - return output.get("value") - - async def set(self, key: str, value: Any) -> None: - """设置键值对。 - - Args: - key: 数据键名 - value: 要存储的 JSON 值 - - 示例: - await ctx.db.set("user_settings", {"theme": "dark", "lang": "zh"}) - await ctx.db.set("greeted", True) - """ - await self._proxy.call("db.set", {"key": key, "value": value}) - - async def delete(self, key: str) -> None: - """删除指定键的数据。 - - Args: - key: 要删除的数据键名 - - 示例: - await ctx.db.delete("user_settings") - """ - await self._proxy.call("db.delete", {"key": key}) - - async def list(self, prefix: str | None = None) -> list[str]: - """列出匹配前缀的所有键。 - - Args: - prefix: 键前缀过滤,None 表示列出所有键 - - Returns: - 匹配的键名列表 - - 示例: - # 列出所有用户设置相关的键 - keys = await ctx.db.list("user_") - # ["user_settings", "user_profile", "user_history"] - """ - output = await self._proxy.call("db.list", {"prefix": prefix}) - keys = output.get("keys") - if not isinstance(keys, (list, tuple)): - return [] - return [str(item) for item in keys] - - async def get_many(self, keys: Sequence[str]) -> dict[str, Any | None]: - """批量获取多个键的值。 - - Args: - keys: 要读取的键列表 - - Returns: - 一个 dict,key 为键名,value 为对应值(不存在则为 None) - - 示例: - values = await ctx.db.get_many(["user:1", "user:2"]) - if values["user:1"] is None: - print("user:1 missing") - """ - output = await self._proxy.call("db.get_many", {"keys": list(keys)}) - items = output.get("items") - if not isinstance(items, (list, tuple)): - return {} - result: dict[str, Any | None] = {} - for item in items: - if not isinstance(item, dict): - continue - key = item.get("key") - if not isinstance(key, str): - continue - result[key] = item.get("value") - return result - - async def set_many( - self, items: Mapping[str, Any] | Sequence[tuple[str, Any]] - ) -> None: - """批量写入多个键值对。 - - Args: - items: 键值对集合(dict 或二元组序列) - - 示例: - await ctx.db.set_many({"user:1": {"name": "a"}, "user:2": {"name": "b"}}) - """ - if isinstance(items, Mapping): - pairs = list(items.items()) - else: - pairs = list(items) - - payload_items: list[dict[str, Any]] = [ - {"key": str(key), "value": value} for key, value in pairs - ] - await self._proxy.call("db.set_many", {"items": payload_items}) - - def watch(self, prefix: str | None = None) -> AsyncIterator[dict[str, Any]]: - """订阅 KV 变更事件(流式)。 - - Args: - prefix: 键前缀过滤;None 表示订阅所有键 - - Yields: - 变更事件 dict:{"op": "set"|"delete", "key": str, "value": Any|None} - - 示例: - async for event in ctx.db.watch("user:"): - print(event["op"], event["key"]) - """ - return self._proxy.stream("db.watch", {"prefix": prefix}) diff --git a/astrbot-sdk/src/astrbot_sdk/clients/files.py b/astrbot-sdk/src/astrbot_sdk/clients/files.py deleted file mode 100644 index 3a1dd6f6f..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/files.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from ._proxy import CapabilityProxy - - -@dataclass(slots=True) -class FileRegistration: - token: str - url: str - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> FileRegistration: - return cls( - token=str(payload.get("token", "")), - url=str(payload.get("url", "")), - ) - - -class FileServiceClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def register_file( - self, - path: str, - timeout: float | None = None, - ) -> str: - output = await self._proxy.call( - "system.file.register", - {"path": str(path), "timeout": timeout}, - ) - return FileRegistration.from_payload(output).token - - async def handle_file(self, token: str) -> str: - output = await self._proxy.call( - "system.file.handle", - {"token": str(token)}, - ) - return str(output.get("path", "")) - - async def _register_file_url( - self, - path: str, - timeout: float | None = None, - ) -> str: - output = await self._proxy.call( - "system.file.register", - {"path": str(path), "timeout": timeout}, - ) - return FileRegistration.from_payload(output).url diff --git a/astrbot-sdk/src/astrbot_sdk/clients/http.py b/astrbot-sdk/src/astrbot_sdk/clients/http.py deleted file mode 100644 index efec135e8..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/http.py +++ /dev/null @@ -1,165 +0,0 @@ -"""HTTP 客户端模块。 - -提供 HTTP API 注册能力。 - -功能说明: - - 注册自定义 Web API 端点 - - 支持异步请求处理 - - 与宿主 Web 服务器集成 - -设计说明: - 由于跨进程架构,handler 函数无法直接序列化传递。 - 插件需要先声明处理 HTTP 请求的 capability,然后注册路由到 capability 的映射。 - 当前插件身份由运行时在协议层透传,客户端 payload 不暴露 `plugin_id`。 - - 调用流程: - HTTP 请求 → 宿主 Web 服务器 → 查找 route 映射 → invoke capability → Worker 执行 handler → 返回响应 - -示例: - # 插件声明处理 HTTP 请求的 capability - @provide_capability( - name="my_plugin.http_handler", - description="处理 /my-api 的 HTTP 请求", - input_schema={...}, - output_schema={...} - ) - async def handle_http_request(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - - # 注册路由 → capability 映射 - await ctx.http.register_api( - route="/my-api", - methods=["GET", "POST"], - handler_capability="my_plugin.http_handler", - description="我的 API" - ) -""" - -from __future__ import annotations - -from typing import Any - -from ..decorators import get_capability_meta -from ..errors import AstrBotError -from ._proxy import CapabilityProxy - - -def _resolve_handler_capability( - handler_capability: str | None, - handler: Any | None, -) -> str: - if handler_capability and handler is not None: - raise AstrBotError.invalid_input( - "register_api 不能同时提供 handler_capability 和 handler", - hint="请二选一:传 capability 名称字符串,或传 @provide_capability 标记的方法", - ) - if handler_capability: - return handler_capability - if handler is None: - raise AstrBotError.invalid_input( - "register_api 需要提供 handler_capability 或 handler", - hint="示例:handler_capability='demo.http_handler' 或 handler=self.http_handler_capability", - ) - target = getattr(handler, "__func__", handler) - meta = get_capability_meta(target) - if meta is None: - raise AstrBotError.invalid_input( - "register_api(handler=...) 需要传入使用 @provide_capability 声明的方法", - hint="请先用 @provide_capability(name='demo.http_handler', ...) 标记该方法", - ) - return meta.descriptor.name - - -class HTTPClient: - """HTTP 能力客户端。 - - 提供 Web API 注册能力,允许插件暴露自定义 HTTP 端点。 - - Attributes: - _proxy: CapabilityProxy 实例,用于远程能力调用 - """ - - def __init__(self, proxy: CapabilityProxy) -> None: - """初始化 HTTP 客户端。 - - Args: - proxy: CapabilityProxy 实例 - """ - self._proxy = proxy - - async def register_api( - self, - route: str, - handler_capability: str | None = None, - *, - handler: Any | None = None, - methods: list[str] | None = None, - description: str = "", - ) -> None: - """注册 Web API 端点。 - - Args: - route: API 路由路径(如 "/my-api") - handler_capability: 处理此路由的 capability 名称 - handler: 使用 @provide_capability 标记的方法引用 - methods: HTTP 方法列表,默认 ["GET"] - description: API 描述 - - 示例: - await ctx.http.register_api( - route="/my-api", - handler_capability="my_plugin.http_handler", - methods=["GET", "POST"], - description="我的 API" - ) - """ - if methods is None: - methods = ["GET"] - resolved_handler = _resolve_handler_capability(handler_capability, handler) - - await self._proxy.call( - "http.register_api", - { - "route": route, - "methods": methods, - "handler_capability": resolved_handler, - "description": description, - }, - ) - - async def unregister_api( - self, route: str, methods: list[str] | None = None - ) -> None: - """注销 Web API 端点。 - - Args: - route: API 路由路径 - methods: HTTP 方法列表,None 表示所有方法 - - 示例: - await ctx.http.unregister_api("/my-api") - """ - if methods is None: - methods = [] - - await self._proxy.call( - "http.unregister_api", - {"route": route, "methods": methods}, - ) - - async def list_apis(self) -> list[dict[str, Any]]: - """列出当前插件注册的所有 API。 - - Returns: - API 列表,每项包含 route, methods, description - - 示例: - apis = await ctx.http.list_apis() - for api in apis: - print(f"{api['route']}: {api['methods']}") - """ - output = await self._proxy.call( - "http.list_apis", - {}, - ) - return output.get("apis", []) diff --git a/astrbot-sdk/src/astrbot_sdk/clients/llm.py b/astrbot-sdk/src/astrbot_sdk/clients/llm.py deleted file mode 100644 index 14d7393fd..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/llm.py +++ /dev/null @@ -1,293 +0,0 @@ -"""大语言模型客户端模块。 - -提供 v4 原生的 LLM 能力调用接口。 - -设计边界: - - `chat()` 是便捷文本接口,返回最终文本 - - `chat_raw()` 返回完整结构化响应 - - `stream_chat()` 返回文本增量 - - Agent 循环、动态工具注册等更高层 orchestration 不放在客户端内, - 由上层运行时或独立迁移入口承接 -""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Mapping, Sequence -from typing import Any - -from pydantic import BaseModel, Field - -from ._proxy import CapabilityProxy - - -class ChatMessage(BaseModel): - """聊天消息模型。 - - 用于构建对话历史,传递给 LLM。 - - Attributes: - role: 消息角色,如 "user", "assistant", "system" - content: 消息内容 - - 示例: - history = [ - ChatMessage(role="user", content="你好"), - ChatMessage(role="assistant", content="你好!有什么可以帮助你的?"), - ChatMessage(role="user", content="今天天气怎么样?"), - ] - """ - - role: str - content: str - - -ChatHistoryItem = ChatMessage | Mapping[str, Any] - - -def _serialize_history( - history: Sequence[ChatHistoryItem] | None, -) -> list[dict[str, Any]]: - if history is None: - return [] - - serialized: list[dict[str, Any]] = [] - for item in history: - if isinstance(item, ChatMessage): - serialized.append(item.model_dump()) - continue - if isinstance(item, Mapping): - serialized.append(dict(item)) - continue - raise TypeError("history 项必须是 ChatMessage 或 mapping") - return serialized - - -def _normalize_chat_context_payload( - *, - history: Sequence[ChatHistoryItem] | None = None, - contexts: Sequence[ChatHistoryItem] | None = None, -) -> dict[str, list[dict[str, Any]]]: - if contexts is not None: - return {"contexts": _serialize_history(contexts)} - if history is not None: - return {"contexts": _serialize_history(history)} - return {} - - -def _build_chat_payload( - prompt: str, - *, - system: str | None = None, - history: Sequence[ChatHistoryItem] | None = None, - contexts: Sequence[ChatHistoryItem] | None = None, - provider_id: str | None = None, - tool_calls_result: list[dict[str, Any]] | None = None, - model: str | None = None, - temperature: float | None = None, - extra: dict[str, Any] | None = None, -) -> dict[str, Any]: - payload: dict[str, Any] = {"prompt": prompt} - if system is not None: - payload["system"] = system - payload.update(_normalize_chat_context_payload(history=history, contexts=contexts)) - if provider_id is not None: - payload["provider_id"] = provider_id - if tool_calls_result is not None: - payload["tool_calls_result"] = [dict(item) for item in tool_calls_result] - if model is not None: - payload["model"] = model - if temperature is not None: - payload["temperature"] = temperature - if extra: - payload.update(extra) - return payload - - -class LLMResponse(BaseModel): - """LLM 响应模型。 - - 包含完整的 LLM 响应信息,用于 chat_raw() 方法返回。 - - Attributes: - text: 生成的文本内容 - usage: Token 使用统计,如 {"prompt_tokens": 10, "completion_tokens": 20} - finish_reason: 结束原因,如 "stop", "length", "tool_calls" - tool_calls: 工具调用列表(如果 LLM 决定调用工具) - """ - - text: str - usage: dict[str, Any] | None = None - finish_reason: str | None = None - tool_calls: list[dict[str, Any]] = Field(default_factory=list) - role: str | None = None - reasoning_content: str | None = None - reasoning_signature: str | None = None - - -class LLMClient: - """大语言模型客户端。 - - 提供与 LLM 交互的能力,支持普通聊天和流式聊天。 - - Attributes: - _proxy: CapabilityProxy 实例,用于远程能力调用 - """ - - def __init__(self, proxy: CapabilityProxy) -> None: - """初始化 LLM 客户端。 - - Args: - proxy: CapabilityProxy 实例 - """ - self._proxy = proxy - - async def chat( - self, - prompt: str, - *, - system: str | None = None, - history: Sequence[ChatHistoryItem] | None = None, - contexts: Sequence[ChatHistoryItem] | None = None, - provider_id: str | None = None, - tool_calls_result: list[dict[str, Any]] | None = None, - model: str | None = None, - temperature: float | None = None, - **kwargs: Any, - ) -> str: - """发送聊天请求并返回文本响应。 - - 这是简化的聊天接口,仅返回生成的文本内容。 - 如需完整响应信息(包括 usage、tool_calls),请使用 chat_raw()。 - - Args: - prompt: 用户输入的提示文本 - system: 系统提示词,用于指导 LLM 行为 - history: 对话历史,用于保持上下文连续性 - model: 指定使用的模型名称(可选,由核心自动选择) - temperature: 生成温度,控制随机性(0-1) - **kwargs: 额外透传参数,如 `image_urls`、`tools` - - Returns: - LLM 生成的文本内容 - - 示例: - # 简单对话 - reply = await ctx.llm.chat("你好,介绍一下自己") - - # 带历史的对话 - history = [ - ChatMessage(role="user", content="我叫小明"), - ChatMessage(role="assistant", content="你好小明!"), - ] - reply = await ctx.llm.chat("你记得我的名字吗?", history=history) - """ - output = await self._proxy.call( - "llm.chat", - _build_chat_payload( - prompt, - system=system, - history=history, - contexts=contexts, - provider_id=provider_id, - tool_calls_result=tool_calls_result, - model=model, - temperature=temperature, - extra=kwargs, - ), - ) - return str(output.get("text", "")) - - async def chat_raw( - self, - prompt: str, - *, - system: str | None = None, - history: Sequence[ChatHistoryItem] | None = None, - contexts: Sequence[ChatHistoryItem] | None = None, - provider_id: str | None = None, - tool_calls_result: list[dict[str, Any]] | None = None, - model: str | None = None, - temperature: float | None = None, - **kwargs: Any, - ) -> LLMResponse: - """发送聊天请求并返回完整响应。 - - 与 chat() 不同,此方法返回完整的 LLMResponse 对象, - 包含 usage、finish_reason、tool_calls 等信息。 - - Args: - prompt: 用户输入的提示文本 - **kwargs: 额外参数,如 system, history, model, temperature 等 - - Returns: - LLMResponse 对象,包含完整响应信息 - - 示例: - response = await ctx.llm.chat_raw("写一首诗", temperature=0.8) - print(f"生成文本: {response.text}") - print(f"Token 使用: {response.usage}") - """ - payload = _build_chat_payload( - prompt, - system=system, - history=history, - contexts=contexts, - provider_id=provider_id, - tool_calls_result=tool_calls_result, - model=model, - temperature=temperature, - extra=kwargs, - ) - output = await self._proxy.call( - "llm.chat_raw", - payload, - ) - return LLMResponse.model_validate(output) - - async def stream_chat( - self, - prompt: str, - *, - system: str | None = None, - history: Sequence[ChatHistoryItem] | None = None, - contexts: Sequence[ChatHistoryItem] | None = None, - provider_id: str | None = None, - tool_calls_result: list[dict[str, Any]] | None = None, - model: str | None = None, - temperature: float | None = None, - **kwargs: Any, - ) -> AsyncGenerator[str, None]: - """流式聊天,逐块返回响应文本。 - - 适用于需要实时显示生成内容的场景,如聊天界面。 - - Args: - prompt: 用户输入的提示文本 - system: 系统提示词 - history: 对话历史 - model: 指定模型 - temperature: 采样温度 - **kwargs: 额外透传参数,如 `image_urls`、`tools` - - Yields: - 每个生成的文本块 - - 示例: - async for chunk in ctx.llm.stream_chat("讲一个故事"): - print(chunk, end="", flush=True) - """ - async for data in self._proxy.stream( - "llm.stream_chat", - _build_chat_payload( - prompt, - system=system, - history=history, - contexts=contexts, - provider_id=provider_id, - tool_calls_result=tool_calls_result, - model=model, - temperature=temperature, - extra=kwargs, - ), - ): - yield str(data.get("text", "")) diff --git a/astrbot-sdk/src/astrbot_sdk/clients/managers.py b/astrbot-sdk/src/astrbot_sdk/clients/managers.py deleted file mode 100644 index f9214e1c5..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/managers.py +++ /dev/null @@ -1,875 +0,0 @@ -"""Typed SDK manager clients for persona, conversation, and knowledge base.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from ..errors import AstrBotError, ErrorCodes -from ..message.components import ( - BaseMessageComponent, - component_to_payload_sync, - payload_to_component, -) -from ..message.session import MessageSession -from ._proxy import CapabilityProxy - - -class _ManagerModel(BaseModel): - model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) - - def to_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_none=True) - - def to_update_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_unset=True) - - -def _normalize_session(session: str | MessageSession) -> str: - if isinstance(session, MessageSession): - return str(session) - return str(session) - - -def _require_message_history_session( - session: MessageSession, -) -> dict[str, str]: - if not isinstance(session, MessageSession): - raise TypeError( - "message_history requires astrbot_sdk.message.session.MessageSession" - ) - return { - "platform_id": str(session.platform_id), - "message_type": str(session.message_type), - "session_id": str(session.session_id), - } - - -def _normalize_message_history_parts( - parts: list[BaseMessageComponent], -) -> list[dict[str, Any]]: - normalized: list[dict[str, Any]] = [] - for part in parts: - if not isinstance(part, BaseMessageComponent): - raise TypeError( - "message_history.append requires BaseMessageComponent items in parts" - ) - normalized.append(component_to_payload_sync(part)) - return normalized - - -class PersonaRecord(_ManagerModel): - persona_id: str - system_prompt: str - begin_dialogs: list[str] = Field(default_factory=list) - tools: list[str] | None = None - skills: list[str] | None = None - custom_error_message: str | None = None - folder_id: str | None = None - sort_order: int = 0 - created_at: str | None = None - updated_at: str | None = None - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> PersonaRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class PersonaCreateParams(_ManagerModel): - persona_id: str - system_prompt: str - begin_dialogs: list[str] = Field(default_factory=list) - tools: list[str] | None = None - skills: list[str] | None = None - custom_error_message: str | None = None - folder_id: str | None = None - sort_order: int = 0 - - -class PersonaUpdateParams(_ManagerModel): - system_prompt: str | None = None - begin_dialogs: list[str] | None = None - tools: list[str] | None = None - skills: list[str] | None = None - custom_error_message: str | None = None - - -class ConversationRecord(_ManagerModel): - conversation_id: str - session: str - platform_id: str - history: list[dict[str, Any]] = Field(default_factory=list) - title: str | None = None - persona_id: str | None = None - created_at: str | None = None - updated_at: str | None = None - token_usage: int | None = None - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> ConversationRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class ConversationCreateParams(_ManagerModel): - platform_id: str | None = None - history: list[dict[str, Any]] | None = None - title: str | None = None - persona_id: str | None = None - - -class ConversationUpdateParams(_ManagerModel): - history: list[dict[str, Any]] | None = None - title: str | None = None - persona_id: str | None = None - token_usage: int | None = None - - -class MessageHistorySender(_ManagerModel): - sender_id: str | None = None - sender_name: str | None = None - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> MessageHistorySender | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class MessageHistoryRecord(_ManagerModel): - id: int - session: MessageSession - sender: MessageHistorySender = Field(default_factory=MessageHistorySender) - parts: list[BaseMessageComponent] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - created_at: datetime | None = None - updated_at: datetime | None = None - idempotency_key: str | None = None - - @model_validator(mode="before") - @classmethod - def _normalize_payload(cls, value: Any) -> Any: - if not isinstance(value, dict): - return value - normalized = dict(value) - - session_payload = normalized.get("session") - if isinstance(session_payload, dict): - normalized["session"] = MessageSession( - platform_id=str(session_payload.get("platform_id", "")), - message_type=str(session_payload.get("message_type", "")), - session_id=str(session_payload.get("session_id", "")), - ) - - sender_payload = normalized.get("sender") - if isinstance(sender_payload, dict): - normalized["sender"] = MessageHistorySender.model_validate(sender_payload) - elif sender_payload is None: - normalized["sender"] = MessageHistorySender() - - parts_payload = normalized.get("parts") - if isinstance(parts_payload, list): - normalized["parts"] = [ - payload_to_component(item) - for item in parts_payload - if isinstance(item, dict) - ] - - metadata_payload = normalized.get("metadata") - if not isinstance(metadata_payload, dict): - normalized["metadata"] = {} - - return normalized - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> MessageHistoryRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class MessageHistoryPage(_ManagerModel): - records: list[MessageHistoryRecord] = Field(default_factory=list) - next_cursor: str | None = None - total: int | None = None - - @model_validator(mode="before") - @classmethod - def _normalize_payload(cls, value: Any) -> Any: - if not isinstance(value, dict): - return value - normalized = dict(value) - records_payload = normalized.get("records") - if isinstance(records_payload, list): - normalized["records"] = [ - record - for record in ( - MessageHistoryRecord.from_payload(item) - if isinstance(item, dict) - else None - for item in records_payload - ) - if record is not None - ] - return normalized - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> MessageHistoryPage | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class KnowledgeBaseRecord(_ManagerModel): - kb_id: str - kb_name: str - description: str | None = None - emoji: str | None = None - embedding_provider_id: str - rerank_provider_id: str | None = None - chunk_size: int | None = None - chunk_overlap: int | None = None - top_k_dense: int | None = None - top_k_sparse: int | None = None - top_m_final: int | None = None - doc_count: int = 0 - chunk_count: int = 0 - created_at: str | None = None - updated_at: str | None = None - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> KnowledgeBaseRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class KnowledgeBaseCreateParams(_ManagerModel): - kb_name: str - embedding_provider_id: str - description: str | None = None - emoji: str | None = None - rerank_provider_id: str | None = None - chunk_size: int | None = None - chunk_overlap: int | None = None - top_k_dense: int | None = None - top_k_sparse: int | None = None - top_m_final: int | None = None - - -class KnowledgeBaseUpdateParams(_ManagerModel): - kb_name: str | None = None - embedding_provider_id: str | None = None - description: str | None = None - emoji: str | None = None - rerank_provider_id: str | None = None - chunk_size: int | None = None - chunk_overlap: int | None = None - top_k_dense: int | None = None - top_k_sparse: int | None = None - top_m_final: int | None = None - - -class KnowledgeBaseDocumentRecord(_ManagerModel): - doc_id: str - kb_id: str - doc_name: str - file_type: str - file_size: int - file_path: str = "" - chunk_count: int = 0 - media_count: int = 0 - created_at: str | None = None - updated_at: str | None = None - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> KnowledgeBaseDocumentRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class KnowledgeBaseRetrieveResultItem(_ManagerModel): - chunk_id: str - doc_id: str - kb_id: str - kb_name: str - doc_name: str - chunk_index: int - content: str - score: float - char_count: int - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> KnowledgeBaseRetrieveResultItem | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class KnowledgeBaseRetrieveResult(_ManagerModel): - context_text: str - results: list[KnowledgeBaseRetrieveResultItem] = Field(default_factory=list) - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> KnowledgeBaseRetrieveResult | None: - if not isinstance(payload, dict): - return None - items = payload.get("results") - normalized_items = ( - [ - item.model_dump() - for item in ( - KnowledgeBaseRetrieveResultItem.from_payload(candidate) - if isinstance(candidate, dict) - else None - for candidate in items - ) - if item is not None - ] - if isinstance(items, list) - else [] - ) - return cls.model_validate( - { - "context_text": str(payload.get("context_text", "")), - "results": normalized_items, - } - ) - - -class KnowledgeBaseDocumentUploadParams(_ManagerModel): - file_token: str | None = None - url: str | None = None - text: str | None = None - file_name: str | None = None - file_type: str | None = None - chunk_size: int | None = None - chunk_overlap: int | None = None - batch_size: int | None = None - tasks_limit: int | None = None - max_retries: int | None = None - enable_cleaning: bool | None = None - cleaning_provider_id: str | None = None - - @model_validator(mode="after") - def _validate_source(self) -> KnowledgeBaseDocumentUploadParams: - if any( - isinstance(value, str) and value.strip() - for value in (self.file_token, self.url, self.text) - ): - return self - raise ValueError( - "knowledge base document upload requires file_token, url, or text" - ) - - -class PersonaManagerClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def get_persona(self, persona_id: str) -> PersonaRecord: - try: - output = await self._proxy.call( - "persona.get", - {"persona_id": str(persona_id)}, - ) - except AstrBotError as exc: - if exc.code == ErrorCodes.INVALID_INPUT: - raise ValueError(f"persona not found: {persona_id}") from exc - raise - persona = PersonaRecord.from_payload(output.get("persona")) - if persona is None: - raise ValueError(f"persona not found: {persona_id}") - return persona - - async def get_all_personas(self) -> list[PersonaRecord]: - output = await self._proxy.call("persona.list", {}) - items = output.get("personas") - if not isinstance(items, list): - return [] - return [ - persona - for persona in ( - PersonaRecord.from_payload(item) if isinstance(item, dict) else None - for item in items - ) - if persona is not None - ] - - async def create_persona(self, params: PersonaCreateParams) -> PersonaRecord: - output = await self._proxy.call( - "persona.create", - {"persona": params.to_payload()}, - ) - persona = PersonaRecord.from_payload(output.get("persona")) - if persona is None: - raise ValueError("persona.create returned no persona") - return persona - - async def update_persona( - self, - persona_id: str, - params: PersonaUpdateParams, - ) -> PersonaRecord | None: - output = await self._proxy.call( - "persona.update", - {"persona_id": str(persona_id), "persona": params.to_update_payload()}, - ) - return PersonaRecord.from_payload(output.get("persona")) - - async def delete_persona(self, persona_id: str) -> None: - await self._proxy.call("persona.delete", {"persona_id": str(persona_id)}) - - -class ConversationManagerClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def new_conversation( - self, - session: str | MessageSession, - params: ConversationCreateParams | None = None, - ) -> str: - output = await self._proxy.call( - "conversation.new", - { - "session": _normalize_session(session), - "conversation": (params.to_payload() if params is not None else {}), - }, - ) - return str(output.get("conversation_id", "")) - - async def switch_conversation( - self, - session: str | MessageSession, - conversation_id: str, - ) -> None: - await self._proxy.call( - "conversation.switch", - { - "session": _normalize_session(session), - "conversation_id": str(conversation_id), - }, - ) - - async def delete_conversation( - self, - session: str | MessageSession, - conversation_id: str | None = None, - ) -> None: - """Delete one conversation for the session. - - When ``conversation_id`` is ``None``, this deletes the current selected - conversation for the session only. It does not delete all conversations - under the session. - """ - - await self._proxy.call( - "conversation.delete", - { - "session": _normalize_session(session), - "conversation_id": conversation_id, - }, - ) - - async def get_conversation( - self, - session: str | MessageSession, - conversation_id: str, - *, - create_if_not_exists: bool = False, - ) -> ConversationRecord | None: - output = await self._proxy.call( - "conversation.get", - { - "session": _normalize_session(session), - "conversation_id": str(conversation_id), - "create_if_not_exists": bool(create_if_not_exists), - }, - ) - return ConversationRecord.from_payload(output.get("conversation")) - - async def get_current_conversation( - self, - session: str | MessageSession, - *, - create_if_not_exists: bool = False, - ) -> ConversationRecord | None: - output = await self._proxy.call( - "conversation.get_current", - { - "session": _normalize_session(session), - "create_if_not_exists": bool(create_if_not_exists), - }, - ) - return ConversationRecord.from_payload(output.get("conversation")) - - async def get_conversations( - self, - session: str | MessageSession | None = None, - *, - platform_id: str | None = None, - ) -> list[ConversationRecord]: - output = await self._proxy.call( - "conversation.list", - { - "session": ( - _normalize_session(session) if session is not None else None - ), - "platform_id": platform_id, - }, - ) - items = output.get("conversations") - if not isinstance(items, list): - return [] - return [ - conversation - for conversation in ( - ConversationRecord.from_payload(item) - if isinstance(item, dict) - else None - for item in items - ) - if conversation is not None - ] - - async def update_conversation( - self, - session: str | MessageSession, - conversation_id: str | None = None, - params: ConversationUpdateParams | None = None, - ) -> None: - await self._proxy.call( - "conversation.update", - { - "session": _normalize_session(session), - "conversation_id": conversation_id, - "conversation": ( - params.to_update_payload() if params is not None else {} - ), - }, - ) - - async def unset_persona( - self, - session: str | MessageSession, - conversation_id: str | None = None, - ) -> None: - await self._proxy.call( - "conversation.unset_persona", - { - "session": _normalize_session(session), - "conversation_id": conversation_id, - }, - ) - - -class MessageHistoryManagerClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def list( - self, - session: MessageSession, - *, - cursor: str | None = None, - limit: int = 50, - ) -> MessageHistoryPage: - output = await self._proxy.call( - "message_history.list", - { - "session": _require_message_history_session(session), - "cursor": str(cursor) if cursor is not None else None, - "limit": int(limit), - }, - ) - page = MessageHistoryPage.from_payload(output.get("page")) - if page is None: - raise ValueError("message_history.list returned no page") - return page - - async def get( - self, - session: MessageSession, - record_id: int, - ) -> MessageHistoryRecord | None: - output = await self._proxy.call( - "message_history.get_by_id", - { - "session": _require_message_history_session(session), - "record_id": int(record_id), - }, - ) - return MessageHistoryRecord.from_payload(output.get("record")) - - async def get_by_id( - self, - session: MessageSession, - record_id: int, - ) -> MessageHistoryRecord | None: - return await self.get(session, record_id) - - async def append( - self, - session: MessageSession, - *, - parts: list[BaseMessageComponent], - sender: MessageHistorySender, - metadata: dict[str, Any] | None = None, - idempotency_key: str | None = None, - ) -> MessageHistoryRecord: - if isinstance(sender, MessageHistorySender): - sender_payload = sender.to_payload() - elif isinstance(sender, dict): - sender_payload = MessageHistorySender.model_validate(sender).to_payload() - else: - raise TypeError( - "message_history.append requires MessageHistorySender for sender" - ) - output = await self._proxy.call( - "message_history.append", - { - "session": _require_message_history_session(session), - "sender": sender_payload, - "parts": _normalize_message_history_parts(parts), - "metadata": dict(metadata or {}), - "idempotency_key": ( - str(idempotency_key) if idempotency_key is not None else None - ), - }, - ) - record = MessageHistoryRecord.from_payload(output.get("record")) - if record is None: - raise ValueError("message_history.append returned no record") - return record - - async def delete_before( - self, - session: MessageSession, - *, - before: datetime, - ) -> int: - output = await self._proxy.call( - "message_history.delete_before", - { - "session": _require_message_history_session(session), - "before": before.isoformat(), - }, - ) - return int(output.get("deleted_count", 0) or 0) - - async def delete_after( - self, - session: MessageSession, - *, - after: datetime, - ) -> int: - output = await self._proxy.call( - "message_history.delete_after", - { - "session": _require_message_history_session(session), - "after": after.isoformat(), - }, - ) - return int(output.get("deleted_count", 0) or 0) - - async def delete_all(self, session: MessageSession) -> int: - output = await self._proxy.call( - "message_history.delete_all", - {"session": _require_message_history_session(session)}, - ) - return int(output.get("deleted_count", 0) or 0) - - -class KnowledgeBaseManagerClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def list_kbs(self) -> list[KnowledgeBaseRecord]: - output = await self._proxy.call("kb.list", {}) - items = output.get("kbs") - if not isinstance(items, list): - return [] - return [ - kb - for kb in ( - KnowledgeBaseRecord.from_payload(item) - if isinstance(item, dict) - else None - for item in items - ) - if kb is not None - ] - - async def get_kb(self, kb_id: str) -> KnowledgeBaseRecord | None: - output = await self._proxy.call("kb.get", {"kb_id": str(kb_id)}) - return KnowledgeBaseRecord.from_payload(output.get("kb")) - - async def create_kb( - self, - params: KnowledgeBaseCreateParams, - ) -> KnowledgeBaseRecord: - output = await self._proxy.call("kb.create", {"kb": params.to_payload()}) - kb = KnowledgeBaseRecord.from_payload(output.get("kb")) - if kb is None: - raise ValueError("kb.create returned no knowledge base") - return kb - - async def update_kb( - self, - kb_id: str, - params: KnowledgeBaseUpdateParams, - ) -> KnowledgeBaseRecord | None: - output = await self._proxy.call( - "kb.update", - {"kb_id": str(kb_id), "kb": params.to_update_payload()}, - ) - return KnowledgeBaseRecord.from_payload(output.get("kb")) - - async def delete_kb(self, kb_id: str) -> bool: - output = await self._proxy.call("kb.delete", {"kb_id": str(kb_id)}) - return bool(output.get("deleted", False)) - - async def retrieve( - self, - query: str, - *, - kb_ids: list[str] | None = None, - kb_names: list[str] | None = None, - top_k_fusion: int | None = None, - top_m_final: int | None = None, - ) -> KnowledgeBaseRetrieveResult | None: - request_payload: dict[str, Any] = { - "query": str(query), - "kb_ids": [str(item) for item in (kb_ids or [])], - "kb_names": [str(item) for item in (kb_names or [])], - } - if top_k_fusion is not None: - request_payload["top_k_fusion"] = int(top_k_fusion) - if top_m_final is not None: - request_payload["top_m_final"] = int(top_m_final) - output = await self._proxy.call( - "kb.retrieve", - request_payload, - ) - return KnowledgeBaseRetrieveResult.from_payload(output.get("result")) - - async def upload_document( - self, - kb_id: str, - params: KnowledgeBaseDocumentUploadParams, - ) -> KnowledgeBaseDocumentRecord: - output = await self._proxy.call( - "kb.document.upload", - {"kb_id": str(kb_id), "document": params.to_payload()}, - ) - document = KnowledgeBaseDocumentRecord.from_payload(output.get("document")) - if document is None: - raise ValueError("kb.document.upload returned no document") - return document - - async def list_documents( - self, - kb_id: str, - *, - offset: int = 0, - limit: int = 100, - ) -> list[KnowledgeBaseDocumentRecord]: - output = await self._proxy.call( - "kb.document.list", - {"kb_id": str(kb_id), "offset": int(offset), "limit": int(limit)}, - ) - items = output.get("documents") - if not isinstance(items, list): - return [] - return [ - document - for document in ( - KnowledgeBaseDocumentRecord.from_payload(item) - if isinstance(item, dict) - else None - for item in items - ) - if document is not None - ] - - async def get_document( - self, - kb_id: str, - doc_id: str, - ) -> KnowledgeBaseDocumentRecord | None: - output = await self._proxy.call( - "kb.document.get", - {"kb_id": str(kb_id), "doc_id": str(doc_id)}, - ) - return KnowledgeBaseDocumentRecord.from_payload(output.get("document")) - - async def delete_document( - self, - kb_id: str, - doc_id: str, - ) -> bool: - output = await self._proxy.call( - "kb.document.delete", - {"kb_id": str(kb_id), "doc_id": str(doc_id)}, - ) - return bool(output.get("deleted", False)) - - async def refresh_document( - self, - kb_id: str, - doc_id: str, - ) -> KnowledgeBaseDocumentRecord | None: - output = await self._proxy.call( - "kb.document.refresh", - {"kb_id": str(kb_id), "doc_id": str(doc_id)}, - ) - return KnowledgeBaseDocumentRecord.from_payload(output.get("document")) - - -__all__ = [ - "ConversationCreateParams", - "ConversationManagerClient", - "ConversationRecord", - "ConversationUpdateParams", - "KnowledgeBaseCreateParams", - "KnowledgeBaseDocumentRecord", - "KnowledgeBaseDocumentUploadParams", - "KnowledgeBaseManagerClient", - "KnowledgeBaseRecord", - "KnowledgeBaseRetrieveResult", - "KnowledgeBaseRetrieveResultItem", - "KnowledgeBaseUpdateParams", - "MessageHistoryManagerClient", - "MessageHistoryPage", - "MessageHistoryRecord", - "MessageHistorySender", - "PersonaCreateParams", - "PersonaManagerClient", - "PersonaRecord", - "PersonaUpdateParams", -] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/mcp.py b/astrbot-sdk/src/astrbot_sdk/clients/mcp.py deleted file mode 100644 index 9e486d523..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/mcp.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import annotations - -from contextlib import AbstractAsyncContextManager -from dataclasses import dataclass, field -from enum import Enum -from typing import Any - -from ..errors import AstrBotError -from ._proxy import CapabilityProxy - - -class MCPServerScope(str, Enum): - local = "local" - global_ = "global" - - -@dataclass(slots=True) -class MCPServerRecord: - name: str - scope: MCPServerScope - active: bool - running: bool - config: dict[str, Any] = field(default_factory=dict) - tools: list[str] = field(default_factory=list) - errlogs: list[str] = field(default_factory=list) - last_error: str | None = None - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> MCPServerRecord | None: - if not isinstance(payload, dict): - return None - scope_value = str(payload.get("scope") or MCPServerScope.local.value).strip() - try: - scope = MCPServerScope(scope_value) - except ValueError: - scope = MCPServerScope.local - return cls( - name=str(payload.get("name", "")), - scope=scope, - active=bool(payload.get("active", False)), - running=bool(payload.get("running", False)), - config=( - dict(payload.get("config")) - if isinstance(payload.get("config"), dict) - else {} - ), - tools=[ - str(item) - for item in payload.get("tools", []) - if isinstance(item, str) and item - ] - if isinstance(payload.get("tools"), list) - else [], - errlogs=[ - str(item) - for item in payload.get("errlogs", []) - if isinstance(item, str) - ] - if isinstance(payload.get("errlogs"), list) - else [], - last_error=( - str(payload.get("last_error")) - if payload.get("last_error") is not None - else None - ), - ) - - -class MCPSession(AbstractAsyncContextManager["MCPSession"]): - def __init__( - self, - proxy: CapabilityProxy, - *, - name: str, - config: dict[str, Any], - timeout: float, - ) -> None: - self._proxy = proxy - self._name = str(name) - self._config = dict(config) - self._timeout = float(timeout) - self._session_id: str | None = None - self._tools: list[str] = [] - - async def __aenter__(self) -> MCPSession: - output = await self._proxy.call( - "mcp.session.open", - { - "name": self._name, - "config": dict(self._config), - "timeout": self._timeout, - }, - ) - session_id = str(output.get("session_id", "")).strip() - if not session_id: - raise ValueError("mcp.session.open returned no session_id") - self._session_id = session_id - tools = output.get("tools") - self._tools = ( - [str(item) for item in tools if isinstance(item, str)] - if isinstance(tools, list) - else [] - ) - return self - - async def __aexit__(self, exc_type, exc, tb) -> None: - session_id = self._session_id - self._session_id = None - self._tools = [] - if not session_id: - return - try: - await self._proxy.call("mcp.session.close", {"session_id": session_id}) - except AstrBotError: - raise - except Exception: - # Session cleanup should not mask the original error raised inside the - # managed block. - if exc_type is None: - raise - - async def call_tool( - self, - tool_name: str, - args: dict[str, Any] | None = None, - ) -> dict[str, Any]: - session_id = self._require_session_id() - output = await self._proxy.call( - "mcp.session.call_tool", - { - "session_id": session_id, - "tool_name": str(tool_name), - "args": dict(args or {}), - }, - ) - result = output.get("result") - if not isinstance(result, dict): - raise ValueError("mcp.session.call_tool returned no result object") - return dict(result) - - async def list_tools(self) -> list[str]: - session_id = self._require_session_id() - output = await self._proxy.call( - "mcp.session.list_tools", - {"session_id": session_id}, - ) - tools = output.get("tools") - self._tools = ( - [str(item) for item in tools if isinstance(item, str)] - if isinstance(tools, list) - else [] - ) - return list(self._tools) - - def _require_session_id(self) -> str: - if self._session_id is None: - raise RuntimeError("MCP session is not active; use 'async with'") - return self._session_id - - -class MCPManagerClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def get_server(self, name: str) -> MCPServerRecord | None: - output = await self._proxy.call("mcp.local.get", {"name": str(name)}) - return MCPServerRecord.from_payload(output.get("server")) - - async def list_servers(self) -> list[MCPServerRecord]: - output = await self._proxy.call("mcp.local.list", {}) - items = output.get("servers") - if not isinstance(items, list): - return [] - return [ - record - for record in ( - MCPServerRecord.from_payload(item) if isinstance(item, dict) else None - for item in items - ) - if record is not None - ] - - async def enable_server(self, name: str) -> MCPServerRecord: - output = await self._proxy.call("mcp.local.enable", {"name": str(name)}) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.local.enable returned no server") - return record - - async def disable_server(self, name: str) -> MCPServerRecord: - output = await self._proxy.call("mcp.local.disable", {"name": str(name)}) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.local.disable returned no server") - return record - - async def wait_until_ready( - self, - name: str, - *, - timeout: float = 30.0, - ) -> MCPServerRecord: - output = await self._proxy.call( - "mcp.local.wait_until_ready", - {"name": str(name), "timeout": float(timeout)}, - ) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.local.wait_until_ready returned no server") - return record - - def session( - self, - name: str, - config: dict[str, Any], - *, - timeout: float = 30.0, - ) -> MCPSession: - return MCPSession( - self._proxy, - name=str(name), - config=dict(config), - timeout=float(timeout), - ) - - async def register_global_server( - self, - name: str, - config: dict[str, Any], - *, - timeout: float = 30.0, - ) -> MCPServerRecord: - output = await self._proxy.call( - "mcp.global.register", - { - "name": str(name), - "config": dict(config), - "timeout": float(timeout), - }, - ) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.global.register returned no server") - return record - - async def get_global_server(self, name: str) -> MCPServerRecord | None: - output = await self._proxy.call("mcp.global.get", {"name": str(name)}) - return MCPServerRecord.from_payload(output.get("server")) - - async def list_global_servers(self) -> list[MCPServerRecord]: - output = await self._proxy.call("mcp.global.list", {}) - items = output.get("servers") - if not isinstance(items, list): - return [] - return [ - record - for record in ( - MCPServerRecord.from_payload(item) if isinstance(item, dict) else None - for item in items - ) - if record is not None - ] - - async def enable_global_server( - self, - name: str, - *, - timeout: float = 30.0, - ) -> MCPServerRecord: - output = await self._proxy.call( - "mcp.global.enable", - {"name": str(name), "timeout": float(timeout)}, - ) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.global.enable returned no server") - return record - - async def disable_global_server(self, name: str) -> MCPServerRecord: - output = await self._proxy.call("mcp.global.disable", {"name": str(name)}) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.global.disable returned no server") - return record - - async def unregister_global_server(self, name: str) -> MCPServerRecord: - output = await self._proxy.call("mcp.global.unregister", {"name": str(name)}) - record = MCPServerRecord.from_payload(output.get("server")) - if record is None: - raise ValueError("mcp.global.unregister returned no server") - return record - - -__all__ = [ - "MCPManagerClient", - "MCPSession", - "MCPServerRecord", - "MCPServerScope", -] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/memory.py b/astrbot-sdk/src/astrbot_sdk/clients/memory.py deleted file mode 100644 index e22219381..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/memory.py +++ /dev/null @@ -1,373 +0,0 @@ -"""记忆客户端模块。 - -提供 AI 记忆存储能力,用于存储和检索对话记忆、用户偏好等上下文数据。 - -设计说明: - MemoryClient 与 DBClient 的区别: - - DBClient: 简单的键值存储,精确匹配 - - MemoryClient: 支持基于当前 bridge 行为的记忆检索,适合 AI 上下文管理 - - 记忆系统可用于: - - 存储用户偏好和设置 - - 记录对话摘要 - - 缓存 AI 推理结果 -""" - -from __future__ import annotations - -from typing import Any, Literal - -from .._internal.memory_utils import join_memory_namespace -from ._proxy import CapabilityProxy - - -def _normalize_search_item(item: Any) -> dict[str, Any] | None: - if not isinstance(item, dict): - return None - normalized = dict(item) - value = normalized.get("value") - if isinstance(value, dict): - for key, payload_value in value.items(): - normalized.setdefault(str(key), payload_value) - return normalized - - -class MemoryClient: - """记忆客户端。 - - 提供 AI 记忆的存储和检索能力。 - - Attributes: - _proxy: CapabilityProxy 实例,用于远程能力调用 - """ - - def __init__( - self, - proxy: CapabilityProxy, - *, - namespace: str | None = None, - ) -> None: - """初始化记忆客户端。 - - Args: - proxy: CapabilityProxy 实例 - """ - self._proxy = proxy - self._namespace = join_memory_namespace(namespace) - - def namespace(self, *parts: Any) -> MemoryClient: - """Create a derived client that operates inside a child namespace.""" - - return MemoryClient( - self._proxy, - namespace=join_memory_namespace(self._namespace, *parts), - ) - - def _resolve_exact_namespace(self, namespace: str | None) -> str: - if namespace is None: - return self._namespace - return join_memory_namespace(self._namespace, namespace) - - def _resolve_scope_namespace(self, namespace: str | None) -> tuple[bool, str]: - if namespace is None: - if self._namespace: - return True, self._namespace - return False, "" - return True, join_memory_namespace(self._namespace, namespace) - - async def search( - self, - query: str, - *, - mode: Literal["auto", "keyword", "vector", "hybrid"] = "auto", - limit: int | None = None, - min_score: float | None = None, - provider_id: str | None = None, - namespace: str | None = None, - include_descendants: bool = True, - ) -> list[dict[str, Any]]: - """搜索记忆项。 - - 默认会在有 embedding provider 时执行 hybrid 检索, - 否则退化为关键词检索。返回结果包含 `score` 与 `match_type` 字段。 - - Args: - query: 搜索查询文本 - mode: 搜索模式,支持 auto/keyword/vector/hybrid - limit: 最大返回条数 - min_score: 最低分数阈值 - provider_id: 指定 embedding provider,默认使用当前激活的 provider - - Returns: - 匹配的记忆项列表,按相关度排序 - - 示例: - results = await ctx.memory.search( - "用户喜欢什么颜色", - mode="hybrid", - limit=5, - ) - for item in results: - print(item["key"], item["score"], item["match_type"]) - """ - payload: dict[str, Any] = {"query": query, "mode": mode} - if limit is not None: - payload["limit"] = limit - if min_score is not None: - payload["min_score"] = min_score - if provider_id is not None: - payload["provider_id"] = provider_id - has_namespace, resolved_namespace = self._resolve_scope_namespace(namespace) - if has_namespace: - payload["namespace"] = resolved_namespace - payload["include_descendants"] = bool(include_descendants) - output = await self._proxy.call("memory.search", payload) - items = output.get("items") - if not isinstance(items, (list, tuple)): - return [] - normalized_items: list[dict[str, Any]] = [] - for item in items: - normalized = _normalize_search_item(item) - if normalized is not None: - normalized_items.append(normalized) - return normalized_items - - async def save( - self, - key: str, - value: dict[str, Any] | None = None, - namespace: str | None = None, - **extra: Any, - ) -> None: - """保存记忆项。 - - 将数据存储到记忆系统,可通过 search() 检索或 get() 精确获取。 - - Args: - key: 记忆项的唯一标识键 - value: 要存储的数据字典 - **extra: 额外的键值对,会合并到 value 中 - Raises: - TypeError: 如果 value 不是 dict 类型 - 示例: - 保存用户偏好 - await ctx.memory.save("user_pref", {"theme": "dark", "lang": "zh"}) - - 使用关键字参数 - await ctx.memory.save("note", None, content="重要笔记", tags=["work"]) - - 使用 embedding_text 显式指定检索文本 - await ctx.memory.save( - "profile", - {"name": "alice", "embedding_text": "Alice 喜欢蓝色和海边"}, - ) - """ - if value is not None and not isinstance(value, dict): - raise TypeError("memory.save 的 value 必须是 dict") - payload = dict(value or {}) - if extra: - payload.update(extra) - request: dict[str, Any] = {"key": key, "value": payload} - request["namespace"] = self._resolve_exact_namespace(namespace) - await self._proxy.call("memory.save", request) - - async def get( - self, - key: str, - *, - namespace: str | None = None, - ) -> dict[str, Any] | None: - """精确获取单个记忆项。 - - 通过唯一键精确获取记忆内容,不经过搜索匹配。 - - Args: - key: 记忆项的唯一键 - - Returns: - 记忆项内容字典,若不存在则返回 None - - 示例: - pref = await ctx.memory.get("user_pref") - if pref: - print(f"用户偏好主题: {pref.get('theme')}") - """ - payload: dict[str, Any] = {"key": key} - payload["namespace"] = self._resolve_exact_namespace(namespace) - output = await self._proxy.call("memory.get", payload) - value = output.get("value") - return value if isinstance(value, dict) else None - - async def delete( - self, - key: str, - *, - namespace: str | None = None, - ) -> None: - """删除记忆项。 - - Args: - key: 要删除的记忆项键名 - - 示例: - await ctx.memory.delete("old_note") - """ - payload: dict[str, Any] = {"key": key} - payload["namespace"] = self._resolve_exact_namespace(namespace) - await self._proxy.call("memory.delete", payload) - - async def save_with_ttl( - self, - key: str, - value: dict[str, Any], - ttl_seconds: int, - *, - namespace: str | None = None, - ) -> None: - """保存带过期时间的记忆项。 - - 与 save() 不同,此方法允许设置记忆项的存活时间(TTL), - 过期后记忆项将自动删除。 - - Args: - key: 记忆项的唯一标识键 - value: 要存储的数据字典 - ttl_seconds: 存活时间(秒),必须大于 0 - - Raises: - TypeError: 如果 value 不是 dict 类型 - ValueError: 如果 ttl_seconds 小于 1 - - 示例: - # 保存临时会话状态,1小时后过期 - await ctx.memory.save_with_ttl( - "session_temp", - {"state": "waiting"}, - ttl_seconds=3600, - ) - """ - if not isinstance(value, dict): - raise TypeError("memory.save_with_ttl 的 value 必须是 dict") - if ttl_seconds < 1: - raise ValueError("ttl_seconds 必须大于 0") - payload: dict[str, Any] = { - "key": key, - "value": value, - "ttl_seconds": ttl_seconds, - } - payload["namespace"] = self._resolve_exact_namespace(namespace) - await self._proxy.call("memory.save_with_ttl", payload) - - async def get_many( - self, - keys: list[str], - *, - namespace: str | None = None, - ) -> list[dict[str, Any]]: - """批量获取多个记忆项。 - - 一次性获取多个键对应的记忆内容,比多次调用 get() 更高效。 - - Args: - keys: 记忆项键名列表 - - Returns: - 记忆项列表,每项包含 key 和 value 字段, - 不存在的键返回 value 为 None - - 示例: - items = await ctx.memory.get_many(["pref1", "pref2", "pref3"]) - for item in items: - if item["value"]: - print(f"{item['key']}: {item['value']}") - """ - payload: dict[str, Any] = {"keys": keys} - payload["namespace"] = self._resolve_exact_namespace(namespace) - output = await self._proxy.call("memory.get_many", payload) - items = output.get("items") - if not isinstance(items, (list, tuple)): - return [] - return [dict(item) for item in items if isinstance(item, dict)] - - async def delete_many( - self, - keys: list[str], - *, - namespace: str | None = None, - ) -> int: - """批量删除多个记忆项。 - - 一次性删除多个键对应的记忆项,返回实际删除的数量。 - - Args: - keys: 要删除的记忆项键名列表 - - Returns: - 实际删除的记忆项数量 - - 示例: - deleted = await ctx.memory.delete_many(["old1", "old2", "old3"]) - print(f"删除了 {deleted} 条记忆") - """ - payload: dict[str, Any] = {"keys": keys} - payload["namespace"] = self._resolve_exact_namespace(namespace) - output = await self._proxy.call("memory.delete_many", payload) - return int(output.get("deleted_count", 0)) - - async def stats( - self, - *, - namespace: str | None = None, - include_descendants: bool = True, - ) -> dict[str, Any]: - """获取记忆系统统计信息。 - - 返回记忆系统的当前状态,包括条目数、索引状态和脏索引数量。 - - Returns: - 统计信息字典,包含: - - total_items: 总记忆条目数 - - total_bytes: 总占用字节数(可选) - - ttl_entries: 带过期时间的条目数(可选) - - indexed_items: 已建立检索索引的条目数(可选) - - embedded_items: 已生成向量的条目数(可选) - - dirty_items: 等待重建索引的条目数(可选) - - 示例: - stats = await ctx.memory.stats() - print(f"记忆库共有 {stats['total_items']} 条记录") - if "embedded_items" in stats: - print(f"其中 {stats['embedded_items']} 条已经向量化") - """ - payload: dict[str, Any] = { - "include_descendants": bool(include_descendants), - } - has_namespace, resolved_namespace = self._resolve_scope_namespace(namespace) - if has_namespace: - payload["namespace"] = resolved_namespace - output = await self._proxy.call("memory.stats", payload) - stats = { - "total_items": output.get("total_items", 0), - "total_bytes": output.get("total_bytes"), - } - if "namespace" in output: - stats["namespace"] = output.get("namespace") - if "namespace_count" in output: - stats["namespace_count"] = output.get("namespace_count") - if "fts_enabled" in output: - stats["fts_enabled"] = output.get("fts_enabled") - if "vector_backend" in output: - stats["vector_backend"] = output.get("vector_backend") - if "vector_indexes" in output: - stats["vector_indexes"] = output.get("vector_indexes") - if "plugin_id" in output: - stats["plugin_id"] = output.get("plugin_id") - if "ttl_entries" in output: - stats["ttl_entries"] = output.get("ttl_entries") - if "indexed_items" in output: - stats["indexed_items"] = output.get("indexed_items") - if "embedded_items" in output: - stats["embedded_items"] = output.get("embedded_items") - if "dirty_items" in output: - stats["dirty_items"] = output.get("dirty_items") - return stats diff --git a/astrbot-sdk/src/astrbot_sdk/clients/metadata.py b/astrbot-sdk/src/astrbot_sdk/clients/metadata.py deleted file mode 100644 index 92185b64e..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/metadata.py +++ /dev/null @@ -1,111 +0,0 @@ -"""元数据客户端模块。 - -提供插件元数据查询能力。 - -功能说明: - - 查询已加载插件信息 - - 获取插件列表 - - 访问当前插件配置 - -安全边界: - 插件身份由运行时透传到协议层;客户端只暴露业务参数,不接受外部指定调用者。 -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from ._proxy import CapabilityProxy - - -@dataclass -class StarMetadata: - """插件元数据。""" - - name: str - display_name: str - description: str - author: str - version: str - enabled: bool = True - support_platforms: list[str] = field(default_factory=list) - astrbot_version: str | None = None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> StarMetadata: - raw_support_platforms = data.get("support_platforms") - support_platforms = ( - [str(item) for item in raw_support_platforms if isinstance(item, str)] - if isinstance(raw_support_platforms, list) - else [] - ) - return cls( - name=str(data.get("name", "")), - display_name=str(data.get("display_name", data.get("name", ""))), - description=str(data.get("desc", data.get("description", ""))), - author=str(data.get("author", "")), - version=str(data.get("version", "0.0.0")), - enabled=bool(data.get("enabled", True)), - support_platforms=support_platforms, - astrbot_version=( - str(data.get("astrbot_version")) - if data.get("astrbot_version") is not None - else None - ), - ) - - -PluginMetadata = StarMetadata - - -class MetadataClient: - """元数据能力客户端。""" - - def __init__(self, proxy: CapabilityProxy, plugin_id: str) -> None: - self._proxy = proxy - self._plugin_id = plugin_id - - async def get_plugin(self, name: str) -> StarMetadata | None: - output = await self._proxy.call( - "metadata.get_plugin", - {"name": name}, - ) - data = output.get("plugin") - if data is None: - return None - return StarMetadata.from_dict(data) - - async def list_plugins(self) -> list[StarMetadata]: - output = await self._proxy.call("metadata.list_plugins", {}) - items = output.get("plugins", []) - return [ - StarMetadata.from_dict(item) for item in items if isinstance(item, dict) - ] - - async def get_current_plugin(self) -> StarMetadata | None: - return await self.get_plugin(self._plugin_id) - - async def get_plugin_config(self, name: str | None = None) -> dict[str, Any] | None: - target = name or self._plugin_id - if target != self._plugin_id: - raise PermissionError( - "get_plugin_config 只允许访问当前插件自己的配置," - f"请求的插件 '{target}' 不是当前插件 '{self._plugin_id}'" - ) - output = await self._proxy.call( - "metadata.get_plugin_config", - {"name": target}, - ) - config = output.get("config") - return dict(config) if isinstance(config, dict) else None - - async def save_plugin_config(self, config: dict[str, Any]) -> dict[str, Any]: - if not isinstance(config, dict): - raise TypeError("save_plugin_config requires a dict payload") - output = await self._proxy.call( - "metadata.save_plugin_config", - {"config": dict(config)}, - ) - saved = output.get("config") - return dict(saved) if isinstance(saved, dict) else {} diff --git a/astrbot-sdk/src/astrbot_sdk/clients/platform.py b/astrbot-sdk/src/astrbot_sdk/clients/platform.py deleted file mode 100644 index 10142a735..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/platform.py +++ /dev/null @@ -1,300 +0,0 @@ -"""平台客户端模块。 - -提供 v4 原生的平台能力调用。 - -设计边界: - - `PlatformClient` 只负责直接的平台 capability - - 迁移期消息桥接由独立迁移入口承接,不放进原生客户端 - - 富消息链通过 `platform.send_chain` 发送,链构建能力位于专门的消息模块 -""" - -from __future__ import annotations - -from collections.abc import Sequence -from enum import Enum -from typing import Any, cast - -from pydantic import BaseModel, ConfigDict, Field - -from ..message.components import BaseMessageComponent, Plain -from ..message.result import MessageChain -from ..message.session import MessageSession -from ..protocol.descriptors import SessionRef -from ._proxy import CapabilityProxy - - -class _PlatformModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class PlatformStatus(str, Enum): - PENDING = "pending" - RUNNING = "running" - ERROR = "error" - STOPPED = "stopped" - - @classmethod - def from_value(cls, value: Any) -> PlatformStatus: - if isinstance(value, cls): - return value - try: - return cls(str(value).strip().lower()) - except ValueError: - return cls.PENDING - - -class PlatformError(_PlatformModel): - message: str - timestamp: str - traceback: str | None = None - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> PlatformError | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class PlatformStats(_PlatformModel): - id: str - type: str - display_name: str - status: PlatformStatus - started_at: str | None = None - error_count: int - last_error: PlatformError | None = None - unified_webhook: bool - meta: dict[str, Any] = Field(default_factory=dict) - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> PlatformStats | None: - if not isinstance(payload, dict): - return None - normalized = dict(payload) - normalized["status"] = PlatformStatus.from_value(payload.get("status")) - normalized["last_error"] = PlatformError.from_payload( - payload.get("last_error") if isinstance(payload, dict) else None - ) - meta = payload.get("meta") - normalized["meta"] = dict(meta) if isinstance(meta, dict) else {} - return cls.model_validate(normalized) - - -class PlatformClient: - """平台消息客户端。 - - 提供向聊天平台发送消息和获取信息的能力。 - - Attributes: - _proxy: CapabilityProxy 实例,用于远程能力调用 - """ - - def __init__(self, proxy: CapabilityProxy) -> None: - """初始化平台客户端。 - - Args: - proxy: CapabilityProxy 实例 - """ - self._proxy = proxy - - def _build_target_payload( - self, - session: str | SessionRef | MessageSession, - ) -> tuple[str, dict[str, Any]]: - if isinstance(session, SessionRef): - return session.session, {"target": session.to_payload()} - if isinstance(session, MessageSession): - return str(session), {} - return str(session), {} - - async def _coerce_chain_payload( - self, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - ) -> list[dict[str, Any]]: - if isinstance(content, str): - return await MessageChain( - [Plain(content, convert=False)] - ).to_payload_async() - if isinstance(content, MessageChain): - return await content.to_payload_async() - if ( - isinstance(content, Sequence) - and not isinstance(content, (str, bytes)) - and all(isinstance(item, BaseMessageComponent) for item in content) - ): - components = cast(Sequence[BaseMessageComponent], content) - return await MessageChain(list(components)).to_payload_async() - if ( - isinstance(content, Sequence) - and not isinstance(content, (str, bytes)) - and all(isinstance(item, dict) for item in content) - ): - payload_items = cast(Sequence[dict[str, Any]], content) - return [dict(item) for item in payload_items] - raise TypeError( - "content must be str, MessageChain, sequence of message components, " - "or sequence of platform.send_chain payload dicts" - ) - - async def send( - self, - session: str | SessionRef | MessageSession, - text: str, - ) -> dict[str, Any]: - """发送文本消息。 - - 向指定的会话(用户或群组)发送文本消息。 - - Args: - session: 统一消息来源标识 (UMO),格式如 "platform:instance:user_id" - text: 要发送的文本内容 - - Returns: - 发送结果,可能包含消息 ID 等信息 - - 示例: - # 发送消息到当前会话 - await ctx.platform.send(event.session_id, "收到您的消息!") - """ - session_id, extra = self._build_target_payload(session) - return await self._proxy.call( - "platform.send", - {"session": session_id, "text": text, **extra}, - ) - - async def send_image( - self, - session: str | SessionRef | MessageSession, - image_url: str, - ) -> dict[str, Any]: - """发送图片消息。 - - 向指定的会话发送图片,支持 URL 或本地路径。 - - Args: - session: 统一消息来源标识 (UMO) - image_url: 图片 URL 或本地文件路径 - - Returns: - 发送结果 - - 示例: - await ctx.platform.send_image( - event.session_id, - "https://example.com/image.png" - ) - """ - session_id, extra = self._build_target_payload(session) - return await self._proxy.call( - "platform.send_image", - {"session": session_id, "image_url": image_url, **extra}, - ) - - async def send_chain( - self, - session: str | SessionRef | MessageSession, - chain: MessageChain | Sequence[BaseMessageComponent] | Sequence[dict[str, Any]], - ) -> dict[str, Any]: - """发送富消息链。 - - Args: - session: 统一消息来源标识 (UMO) - chain: 序列化后的消息组件数组 - - Returns: - 发送结果 - """ - session_id, extra = self._build_target_payload(session) - chain_payload = await self._coerce_chain_payload(chain) - return await self._proxy.call( - "platform.send_chain", - {"session": session_id, "chain": chain_payload, **extra}, - ) - - async def send_by_session( - self, - session: str | MessageSession, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - ) -> dict[str, Any]: - """主动向指定会话发送消息链。 - - `Sequence[dict]` 的结构与 `platform.send_chain` 完全一致: - 每一项都应是 `{"type": "...", "data": {...}}`。 - """ - chain_payload = await self._coerce_chain_payload(content) - session_id = str(session) - return await self._proxy.call( - "platform.send_by_session", - {"session": session_id, "chain": chain_payload}, - ) - - async def send_by_id( - self, - platform_id: str, - session_id: str, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - *, - message_type: str = "private", - ) -> dict[str, Any]: - """主动向指定平台会话发送消息。""" - session = MessageSession( - platform_id=str(platform_id), - message_type=str(message_type), - session_id=str(session_id), - ) - return await self.send_by_session(session, content) - - async def get_members( - self, - session: str | SessionRef | MessageSession, - ) -> list[dict[str, Any]]: - """获取群组成员列表。 - - 获取指定群组的成员信息列表。注意仅对群组会话有效。 - - Args: - session: 群组会话的统一消息来源标识 (UMO) - - Returns: - 成员信息列表,每个成员是一个字典,可能包含: - - user_id: 用户 ID - - nickname: 昵称 - - role: 角色 (owner, admin, member) - - 示例: - members = await ctx.platform.get_members(event.session_id) - for member in members: - print(f"{member['nickname']} ({member['user_id']})") - """ - session_id, extra = self._build_target_payload(session) - output = await self._proxy.call( - "platform.get_members", - {"session": session_id, **extra}, - ) - members = output.get("members") - if not isinstance(members, (list, tuple)): - return [] - return list(members) - - -__all__ = [ - "PlatformClient", - "PlatformError", - "PlatformStats", - "PlatformStatus", -] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/provider.py b/astrbot-sdk/src/astrbot_sdk/clients/provider.py deleted file mode 100644 index 20bf274c2..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/provider.py +++ /dev/null @@ -1,349 +0,0 @@ -"""Provider discovery and provider-management clients.""" - -from __future__ import annotations - -import asyncio -import contextlib -import inspect -from collections.abc import AsyncIterator, Awaitable, Callable -from typing import Any - -from pydantic import BaseModel, ConfigDict - -from ..llm.entities import ProviderMeta, ProviderType -from ..llm.providers import ( - ProviderProxy, - STTProvider, - TTSProvider, - provider_proxy_from_meta, -) -from ._proxy import CapabilityProxy - - -class _ProviderModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - def to_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_none=True) - - -class ManagedProviderRecord(_ProviderModel): - id: str - model: str | None = None - type: str - provider_type: ProviderType - loaded: bool - enabled: bool - provider_source_id: str | None = None - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> ManagedProviderRecord | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class ProviderChangeEvent(_ProviderModel): - provider_id: str - provider_type: ProviderType - umo: str | None = None - - @classmethod - def from_payload( - cls, - payload: dict[str, Any] | None, - ) -> ProviderChangeEvent | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class ProviderClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - @staticmethod - def _provider_meta_list(items: Any) -> list[ProviderMeta]: - if not isinstance(items, list): - return [] - providers: list[ProviderMeta] = [] - for item in items: - if not isinstance(item, dict): - continue - provider = ProviderMeta.from_payload(item) - if provider is not None: - providers.append(provider) - return providers - - async def list_all(self) -> list[ProviderMeta]: - output = await self._proxy.call("provider.list_all", {}) - return self._provider_meta_list(output.get("providers")) - - async def list_tts(self) -> list[ProviderMeta]: - output = await self._proxy.call("provider.list_all_tts", {}) - return self._provider_meta_list(output.get("providers")) - - async def list_stt(self) -> list[ProviderMeta]: - output = await self._proxy.call("provider.list_all_stt", {}) - return self._provider_meta_list(output.get("providers")) - - async def list_embedding(self) -> list[ProviderMeta]: - output = await self._proxy.call("provider.list_all_embedding", {}) - return self._provider_meta_list(output.get("providers")) - - async def list_rerank(self) -> list[ProviderMeta]: - output = await self._proxy.call("provider.list_all_rerank", {}) - return self._provider_meta_list(output.get("providers")) - - async def _get_tts_support_stream(self, provider_id: str) -> bool: - output = await self._proxy.call( - "provider.tts.support_stream", - {"provider_id": str(provider_id)}, - ) - return bool(output.get("supported", False)) - - async def _build_proxy(self, meta: ProviderMeta | None) -> ProviderProxy | None: - if meta is None: - return None - tts_supports_stream = None - if meta.provider_type == ProviderType.TEXT_TO_SPEECH: - tts_supports_stream = await self._get_tts_support_stream(meta.id) - return provider_proxy_from_meta( - self._proxy, - meta, - tts_supports_stream=tts_supports_stream, - ) - - async def get(self, provider_id: str) -> ProviderProxy | None: - output = await self._proxy.call( - "provider.get_by_id", - {"provider_id": str(provider_id)}, - ) - return await self._build_proxy( - ProviderMeta.from_payload(output.get("provider")) - ) - - async def get_using_chat(self, umo: str | None = None) -> ProviderMeta | None: - output = await self._proxy.call("provider.get_using", {"umo": umo}) - return ProviderMeta.from_payload(output.get("provider")) - - async def get_using_tts(self, umo: str | None = None) -> TTSProvider | None: - output = await self._proxy.call("provider.get_using_tts", {"umo": umo}) - provider = await self._build_proxy( - ProviderMeta.from_payload(output.get("provider")) - ) - return provider if isinstance(provider, TTSProvider) else None - - async def get_using_stt(self, umo: str | None = None) -> STTProvider | None: - output = await self._proxy.call("provider.get_using_stt", {"umo": umo}) - provider = await self._build_proxy( - ProviderMeta.from_payload(output.get("provider")) - ) - return provider if isinstance(provider, STTProvider) else None - - -class ProviderManagerClient: - def __init__( - self, - proxy: CapabilityProxy, - *, - plugin_id: str | None = None, - logger: Any | None = None, - ) -> None: - self._proxy = proxy - self._plugin_id = plugin_id - self._logger = logger - self._change_hook_tasks: set[asyncio.Task[None]] = set() - - @staticmethod - def _provider_type_value(provider_type: ProviderType | str) -> str: - if isinstance(provider_type, ProviderType): - return provider_type.value - return str(provider_type).strip() - - @staticmethod - def _record_from_output(output: dict[str, Any]) -> ManagedProviderRecord | None: - return ManagedProviderRecord.from_payload(output.get("provider")) - - async def set_provider( - self, - provider_id: str, - provider_type: ProviderType | str, - umo: str | None = None, - ) -> None: - await self._proxy.call( - "provider.manager.set", - { - "provider_id": str(provider_id), - "provider_type": self._provider_type_value(provider_type), - "umo": umo, - }, - ) - - async def get_provider_by_id( - self, - provider_id: str, - ) -> ManagedProviderRecord | None: - output = await self._proxy.call( - "provider.manager.get_by_id", - {"provider_id": str(provider_id)}, - ) - return self._record_from_output(output) - - async def get_merged_provider_config( - self, - provider_id: str, - ) -> dict[str, Any] | None: - output = await self._proxy.call( - "provider.manager.get_merged_provider_config", - {"provider_id": str(provider_id).strip()}, - ) - config = output.get("config") - return dict(config) if isinstance(config, dict) else None - - async def load_provider( - self, - provider_config: dict[str, Any], - ) -> ManagedProviderRecord | None: - output = await self._proxy.call( - "provider.manager.load", - {"provider_config": dict(provider_config)}, - ) - return self._record_from_output(output) - - async def terminate_provider(self, provider_id: str) -> None: - await self._proxy.call( - "provider.manager.terminate", - {"provider_id": str(provider_id)}, - ) - - async def create_provider( - self, - provider_config: dict[str, Any], - ) -> ManagedProviderRecord | None: - output = await self._proxy.call( - "provider.manager.create", - {"provider_config": dict(provider_config)}, - ) - return self._record_from_output(output) - - async def update_provider( - self, - origin_provider_id: str, - new_config: dict[str, Any], - ) -> ManagedProviderRecord | None: - output = await self._proxy.call( - "provider.manager.update", - { - "origin_provider_id": str(origin_provider_id), - "new_config": dict(new_config), - }, - ) - return self._record_from_output(output) - - async def delete_provider( - self, - provider_id: str | None = None, - provider_source_id: str | None = None, - ) -> None: - await self._proxy.call( - "provider.manager.delete", - { - "provider_id": provider_id, - "provider_source_id": provider_source_id, - }, - ) - - async def get_insts(self) -> list[ManagedProviderRecord]: - output = await self._proxy.call("provider.manager.get_insts", {}) - items = output.get("providers") - if not isinstance(items, list): - return [] - return [ - record - for record in ( - ManagedProviderRecord.from_payload(item) - if isinstance(item, dict) - else None - for item in items - ) - if record is not None - ] - - async def watch_changes(self) -> AsyncIterator[ProviderChangeEvent]: - async for chunk in self._proxy.stream("provider.manager.watch_changes", {}): - event = ProviderChangeEvent.from_payload(chunk) - if event is not None: - yield event - - async def register_provider_change_hook( - self, - callback: Callable[ - [str, ProviderType, str | None], - Awaitable[None] | None, - ], - ) -> asyncio.Task[None]: - async def runner() -> None: - async for event in self.watch_changes(): - result = callback( - event.provider_id, - event.provider_type, - event.umo, - ) - if inspect.isawaitable(result): - await result - - task = asyncio.create_task(runner()) - self._change_hook_tasks.add(task) - task.add_done_callback(self._log_change_hook_result) - return task - - async def unregister_provider_change_hook( - self, - task: asyncio.Task[None], - ) -> None: - if task not in self._change_hook_tasks: - return - self._change_hook_tasks.discard(task) - if not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - def _log_change_hook_result(self, task: asyncio.Task[None]) -> None: - self._change_hook_tasks.discard(task) - if task.cancelled(): - debug_logger = getattr(self._logger, "debug", None) - if callable(debug_logger): - debug_logger( - "Provider change hook cancelled: plugin_id={}", - self._plugin_id, - ) - return - try: - task.result() - except asyncio.CancelledError: - debug_logger = getattr(self._logger, "debug", None) - if callable(debug_logger): - debug_logger( - "Provider change hook cancelled: plugin_id={}", - self._plugin_id, - ) - except Exception: - exception_logger = getattr(self._logger, "exception", None) - if callable(exception_logger): - exception_logger( - "Provider change hook failed: plugin_id={}", - self._plugin_id, - ) - - -__all__ = [ - "ManagedProviderRecord", - "ProviderChangeEvent", - "ProviderClient", - "ProviderManagerClient", -] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/registry.py b/astrbot-sdk/src/astrbot_sdk/clients/registry.py deleted file mode 100644 index 0a6f9a8ef..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/registry.py +++ /dev/null @@ -1,120 +0,0 @@ -"""只读 handler 注册表客户端。""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from ._proxy import CapabilityProxy - - -def _coerce_int(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -@dataclass(slots=True) -class HandlerMetadata: - plugin_name: str - handler_full_name: str - trigger_type: str - description: str | None = None - event_types: list[str] = field(default_factory=list) - enabled: bool = True - group_path: list[str] = field(default_factory=list) - priority: int = 0 - kind: str = "handler" - require_admin: bool = False - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> HandlerMetadata: - return cls( - plugin_name=str(data.get("plugin_name", "")), - handler_full_name=str(data.get("handler_full_name", "")), - trigger_type=str(data.get("trigger_type", "")), - description=( - None - if data.get("description") is None - else str(data.get("description", "")).strip() or None - ), - event_types=[ - str(item) - for item in data.get("event_types", []) - if isinstance(item, str) - ], - enabled=bool(data.get("enabled", True)), - group_path=[ - str(item) - for item in data.get("group_path", []) - if isinstance(item, str) - ], - priority=_coerce_int(data.get("priority", 0), 0), - kind=str(data.get("kind", "handler") or "handler"), - require_admin=bool(data.get("require_admin", False)), - ) - - -class RegistryClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def get_handlers_by_event_type( - self, - event_type: str, - ) -> list[HandlerMetadata]: - output = await self._proxy.call( - "registry.get_handlers_by_event_type", - {"event_type": event_type}, - ) - return [ - HandlerMetadata.from_dict(item) - for item in output.get("handlers", []) - if isinstance(item, dict) - ] - - async def get_handler_by_full_name( - self, - full_name: str, - ) -> HandlerMetadata | None: - output = await self._proxy.call( - "registry.get_handler_by_full_name", - {"full_name": full_name}, - ) - handler = output.get("handler") - if not isinstance(handler, dict): - return None - return HandlerMetadata.from_dict(handler) - - async def set_handler_whitelist( - self, - plugin_names: list[str] | set[str] | None, - ) -> list[str] | None: - names = None - if plugin_names is not None: - names = sorted({str(item) for item in plugin_names if str(item).strip()}) - output = await self._proxy.call( - "system.event.handler_whitelist.set", - {"plugin_names": names}, - ) - result = output.get("plugin_names") - if not isinstance(result, list): - return None - return [str(item) for item in result] - - async def get_handler_whitelist(self) -> list[str] | None: - output = await self._proxy.call("system.event.handler_whitelist.get", {}) - result = output.get("plugin_names") - if not isinstance(result, list): - return None - return [str(item) for item in result] - - async def clear_handler_whitelist(self) -> None: - await self._proxy.call( - "system.event.handler_whitelist.set", - {"plugin_names": None}, - ) - - -__all__ = ["HandlerMetadata", "RegistryClient"] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/session.py b/astrbot-sdk/src/astrbot_sdk/clients/session.py deleted file mode 100644 index c2901708c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/session.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Session-scoped SDK managers.""" - -from __future__ import annotations - -from typing import Any - -from ..events import MessageEvent -from ..message.session import MessageSession -from ._proxy import CapabilityProxy -from .registry import HandlerMetadata - - -def _normalize_session(session: str | MessageSession | MessageEvent) -> str: - if isinstance(session, MessageEvent): - return str(session.unified_msg_origin) - if isinstance(session, MessageSession): - return str(session) - return str(session) - - -def _handler_to_payload(handler: HandlerMetadata) -> dict[str, Any]: - return { - "plugin_name": handler.plugin_name, - "handler_full_name": handler.handler_full_name, - "trigger_type": handler.trigger_type, - "description": handler.description, - "event_types": list(handler.event_types), - "enabled": handler.enabled, - "group_path": list(handler.group_path), - "priority": handler.priority, - "kind": handler.kind, - "require_admin": handler.require_admin, - } - - -class SessionPluginManager: - """Session-scoped plugin status manager.""" - - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def is_plugin_enabled_for_session( - self, - session: str | MessageSession | MessageEvent, - plugin_name: str, - ) -> bool: - output = await self._proxy.call( - "session.plugin.is_enabled", - { - "session": _normalize_session(session), - "plugin_name": str(plugin_name), - }, - ) - return bool(output.get("enabled", False)) - - async def filter_handlers_by_session( - self, - session: str | MessageSession | MessageEvent, - handlers: list[HandlerMetadata], - ) -> list[HandlerMetadata]: - output = await self._proxy.call( - "session.plugin.filter_handlers", - { - "session": _normalize_session(session), - "handlers": [_handler_to_payload(handler) for handler in handlers], - }, - ) - items = output.get("handlers") - if not isinstance(items, list): - return [] - return [ - HandlerMetadata.from_dict(item) for item in items if isinstance(item, dict) - ] - - -class SessionServiceManager: - """Session-scoped LLM/TTS service status manager.""" - - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def is_llm_enabled_for_session( - self, - session: str | MessageSession | MessageEvent, - ) -> bool: - output = await self._proxy.call( - "session.service.is_llm_enabled", - {"session": _normalize_session(session)}, - ) - return bool(output.get("enabled", False)) - - async def set_llm_status_for_session( - self, - session: str | MessageSession | MessageEvent, - enabled: bool, - ) -> None: - await self._proxy.call( - "session.service.set_llm_status", - {"session": _normalize_session(session), "enabled": bool(enabled)}, - ) - - async def should_process_llm_request( - self, - event_or_session: str | MessageSession | MessageEvent, - ) -> bool: - return await self.is_llm_enabled_for_session(event_or_session) - - async def is_tts_enabled_for_session( - self, - session: str | MessageSession | MessageEvent, - ) -> bool: - output = await self._proxy.call( - "session.service.is_tts_enabled", - {"session": _normalize_session(session)}, - ) - return bool(output.get("enabled", False)) - - async def set_tts_status_for_session( - self, - session: str | MessageSession | MessageEvent, - enabled: bool, - ) -> None: - await self._proxy.call( - "session.service.set_tts_status", - {"session": _normalize_session(session), "enabled": bool(enabled)}, - ) - - async def should_process_tts_request( - self, - event_or_session: str | MessageSession | MessageEvent, - ) -> bool: - return await self.is_tts_enabled_for_session(event_or_session) - - -__all__ = ["SessionPluginManager", "SessionServiceManager"] diff --git a/astrbot-sdk/src/astrbot_sdk/clients/skills.py b/astrbot-sdk/src/astrbot_sdk/clients/skills.py deleted file mode 100644 index 1199e32e9..000000000 --- a/astrbot-sdk/src/astrbot_sdk/clients/skills.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from ._proxy import CapabilityProxy - - -@dataclass(slots=True) -class SkillRegistration: - name: str - description: str - path: str - skill_dir: str - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> SkillRegistration: - return cls( - name=str(data.get("name", "")), - description=str(data.get("description", "") or ""), - path=str(data.get("path", "")), - skill_dir=str(data.get("skill_dir", "")), - ) - - -class SkillClient: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def register( - self, - *, - name: str, - path: str, - description: str = "", - ) -> SkillRegistration: - output = await self._proxy.call( - "skill.register", - { - "name": name, - "path": path, - "description": description, - }, - ) - return SkillRegistration.from_dict(output) - - async def unregister(self, name: str) -> bool: - output = await self._proxy.call("skill.unregister", {"name": name}) - return bool(output.get("removed", False)) - - async def list(self) -> list[SkillRegistration]: - output = await self._proxy.call("skill.list", {}) - return [ - SkillRegistration.from_dict(item) - for item in output.get("skills", []) - if isinstance(item, dict) - ] - - -__all__ = ["SkillClient", "SkillRegistration"] diff --git a/astrbot-sdk/src/astrbot_sdk/commands.py b/astrbot-sdk/src/astrbot_sdk/commands.py deleted file mode 100644 index 0e90ab830..000000000 --- a/astrbot-sdk/src/astrbot_sdk/commands.py +++ /dev/null @@ -1,159 +0,0 @@ -"""SDK-native command group helpers. - -本模块提供命令分组工具,用于组织具有层级关系的命令。 - -CommandGroup 允许以嵌套方式定义命令树,例如: - admin - ├── user - │ ├── add - │ └── remove - └── config - ├── get - └── set - -特性: -- 支持命令别名,自动展开父级路径的所有别名组合 -- 自动生成命令树的可视化输出 (print_cmd_tree) -- 与 @on_command 装饰器无缝集成 -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from itertools import product - -from .decorators import on_command, set_command_route_meta -from .protocol.descriptors import CommandRouteSpec - - -@dataclass(slots=True) -class _CommandNode: - name: str - aliases: list[str] = field(default_factory=list) - description: str | None = None - subgroups: list[CommandGroup] = field(default_factory=list) - commands: list[tuple[str, str | None]] = field(default_factory=list) - - -class CommandGroup: - def __init__( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, - parent: CommandGroup | None = None, - ) -> None: - self.name = name - self.aliases = list(aliases or []) - self.description = description - self.parent = parent - self._tree = _CommandNode( - name=name, aliases=self.aliases, description=description - ) - - def group( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, - ) -> CommandGroup: - child = CommandGroup( - name, - aliases=aliases, - description=description, - parent=self, - ) - self._tree.subgroups.append(child) - return child - - def command( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, - ): - full_command = " ".join([*self.path, name]) - full_aliases = self._expand_aliases(name=name, aliases=aliases or []) - display_command = full_command - route = CommandRouteSpec( - group_path=self.path, - display_command=display_command, - group_help=self.description, - ) - - def decorator(func): - decorated = on_command( - full_command, - aliases=full_aliases, - description=description, - )(func) - self._tree.commands.append((name, description)) - set_command_route_meta(decorated, route) - return decorated - - return decorator - - @property - def path(self) -> list[str]: - if self.parent is None: - return [self.name] - return [*self.parent.path, self.name] - - def print_cmd_tree(self) -> str: - lines: list[str] = [] - self._append_tree_lines(lines, indent=0) - return "\n".join(lines) - - def _append_tree_lines(self, lines: list[str], *, indent: int) -> None: - prefix = " " * indent - label = self.name - if self.aliases: - label += f" ({', '.join(self.aliases)})" - lines.append(f"{prefix}{label}") - for command_name, description in self._tree.commands: - command_label = f"{prefix} - {command_name}" - if description: - command_label += f": {description}" - lines.append(command_label) - for subgroup in self._tree.subgroups: - subgroup._append_tree_lines(lines, indent=indent + 1) - - def _expand_aliases(self, *, name: str, aliases: list[str]) -> list[str]: - group_segments: list[list[str]] = [] - cursor: CommandGroup | None = self - ancestry: list[CommandGroup] = [] - while cursor is not None: - ancestry.append(cursor) - cursor = cursor.parent - for group in reversed(ancestry): - group_segments.append([group.name, *group.aliases]) - leaf_segments = [name, *aliases] - expanded: set[str] = set() - for parts in product(*group_segments, leaf_segments): - route = " ".join(parts) - if route != " ".join([*self.path, name]): - expanded.add(route) - return sorted(expanded) - - -def command_group( - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> CommandGroup: - return CommandGroup( - name, - aliases=aliases, - description=description, - ) - - -def print_cmd_tree(group: CommandGroup) -> str: - return group.print_cmd_tree() - - -__all__ = ["CommandGroup", "command_group", "print_cmd_tree"] diff --git a/astrbot-sdk/src/astrbot_sdk/context.py b/astrbot-sdk/src/astrbot_sdk/context.py deleted file mode 100644 index 1f015e589..000000000 --- a/astrbot-sdk/src/astrbot_sdk/context.py +++ /dev/null @@ -1,729 +0,0 @@ -"""v4 原生运行时上下文。 - -`Context` 是插件与 AstrBot Core 交互的主要入口, -负责组合所有 capability 客户端并提供统一的访问接口。 - -每个 handler 调用都会创建一个新的 Context 实例, -绑定到当前的 Peer、插件 ID 和取消令牌。 - -Attributes: - llm: LLM 能力客户端,用于 AI 对话 - memory: 记忆能力客户端,用于语义存储 - db: 数据库客户端,用于 KV 持久化 - files: 文件服务客户端,用于文件令牌注册与解析 - platform: 平台客户端,用于发送消息 - providers: Provider 客户端,用于查询和调用专用 Provider - provider_manager: Provider 管理客户端,用于 reserved/system 级操作 - personas: 人格管理客户端 - conversations: 对话管理客户端 - kbs: 知识库管理客户端 - message_history: 消息历史管理客户端 - http: HTTP 客户端,用于注册 API 端点 - metadata: 元数据客户端,用于查询插件信息 - mcp: MCP 管理客户端,用于本地/全局 MCP 服务管理 - skills: Skill 客户端,用于向 AstrBot 注册插件技能 - plugin_id: 当前插件的唯一标识 - logger: 绑定了插件 ID 的日志器 - cancel_token: 取消令牌,用于处理请求取消 -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Sequence -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from loguru import logger as base_logger - -from ._internal.plugin_logger import PluginLogger -from ._internal.star_runtime import current_star_instance -from .clients import ( - DBClient, - HTTPClient, - LLMClient, - MCPManagerClient, - MemoryClient, - MetadataClient, - PlatformClient, - PlatformError, - PlatformStats, - PlatformStatus, - RegistryClient, - SkillClient, -) -from .clients._proxy import CapabilityProxy -from .clients.files import FileServiceClient -from .clients.llm import LLMResponse -from .clients.managers import ( - ConversationManagerClient, - KnowledgeBaseManagerClient, - MessageHistoryManagerClient, - PersonaManagerClient, -) -from .clients.provider import ProviderClient, ProviderManagerClient -from .clients.session import SessionPluginManager, SessionServiceManager -from .clients.skills import SkillRegistration -from .errors import AstrBotError -from .llm.entities import LLMToolSpec, ProviderMeta, ProviderRequest -from .llm.tools import LLMToolManager -from .message.components import BaseMessageComponent -from .message.result import MessageChain -from .message.session import MessageSession -from .session_waiter import ( - _mark_session_waiter_background_task, - _unmark_session_waiter_background_task, -) - -PlatformCompatContent = ( - str | MessageChain | Sequence[BaseMessageComponent] | Sequence[dict[str, Any]] -) - - -@dataclass(slots=True) -class PlatformCompatFacade: - """兼容层平台入口,仅暴露安全元信息和主动发送能力。""" - - _ctx: Context - id: str - name: str - type: str - status: PlatformStatus = PlatformStatus.PENDING - errors: list[PlatformError] = field(default_factory=list) - last_error: PlatformError | None = None - unified_webhook: bool = False - _state_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) - - async def send_by_session( - self, - session: str | MessageSession, - content: PlatformCompatContent, - ) -> dict[str, Any]: - return await self._ctx.platform.send_by_session(session, content) - - async def send_by_id( - self, - session_id: str, - content: PlatformCompatContent, - *, - message_type: str = "private", - ) -> dict[str, Any]: - return await self._ctx.platform.send_by_id( - self.id, - session_id, - content, - message_type=message_type, - ) - - async def send( - self, - session: str | MessageSession, - content: PlatformCompatContent, - *, - message_type: str = "private", - ) -> dict[str, Any]: - if isinstance(session, MessageSession): - return await self.send_by_session(session, content) - session_text = str(session).strip() - if ":" in session_text: - return await self.send_by_session(session_text, content) - return await self.send_by_id( - session_text, - content, - message_type=message_type, - ) - - async def refresh(self) -> None: - async with self._state_lock: - await self._refresh_locked() - - async def clear_errors(self) -> None: - async with self._state_lock: - await self._ctx._proxy.call( - "platform.manager.clear_errors", - {"platform_id": self.id}, - ) - await self._refresh_locked() - - async def get_stats(self) -> PlatformStats | None: - output = await self._ctx._proxy.call( - "platform.manager.get_stats", - {"platform_id": self.id}, - ) - return PlatformStats.from_payload(output.get("stats")) - - def _apply_snapshot(self, payload: Any) -> None: - if not isinstance(payload, dict): - return - self.name = str(payload.get("name", self.name)) - self.type = str(payload.get("type", self.type)) - self.status = PlatformStatus.from_value(payload.get("status")) - errors_payload = payload.get("errors") - if isinstance(errors_payload, list): - self.errors = [ - error - for error in ( - PlatformError.from_payload(item) if isinstance(item, dict) else None - for item in errors_payload - ) - if error is not None - ] - self.last_error = PlatformError.from_payload(payload.get("last_error")) - self.unified_webhook = bool(payload.get("unified_webhook", False)) - - async def _refresh_locked(self) -> None: - output = await self._ctx._proxy.call( - "platform.manager.get_by_id", - {"platform_id": self.id}, - ) - self._apply_snapshot(output.get("platform")) - - -@dataclass(slots=True) -class CancelToken: - """请求取消令牌。 - - 用于协调长时间运行操作的取消。当用户取消请求或 - 上游超时时,令牌会被触发,允许 handler 及时清理资源。 - - Example: - async def long_operation(ctx: Context): - for item in large_list: - ctx.cancel_token.raise_if_cancelled() - await process(item) - """ - - _cancelled: asyncio.Event - - def __init__(self) -> None: - self._cancelled = asyncio.Event() - - def cancel(self) -> None: - """触发取消信号。""" - self._cancelled.set() - - @property - def cancelled(self) -> bool: - """检查是否已被取消。""" - return self._cancelled.is_set() - - async def wait(self) -> None: - """等待取消信号。""" - await self._cancelled.wait() - - def raise_if_cancelled(self) -> None: - """如果已取消则抛出 CancelledError。 - - Raises: - asyncio.CancelledError: 如果令牌已被取消 - """ - if self.cancelled: - raise asyncio.CancelledError - - -class Context: - """插件运行时上下文。 - - 组合所有 capability 客户端,提供统一的访问接口。 - 每个 handler 调用都会创建新的 Context 实例。 - - Attributes: - peer: 协议对等端,用于底层通信 - llm: LLM 客户端 - memory: 记忆客户端 - db: 数据库客户端 - platform: 平台客户端 - providers: Provider 客户端 - provider_manager: Provider 管理客户端 - personas: 人格管理客户端 - conversations: 对话管理客户端 - kbs: 知识库管理客户端 - message_history: 消息历史管理客户端 - http: HTTP 客户端 - metadata: 元数据客户端 - mcp: MCP 管理客户端 - plugin_id: 当前插件 ID - logger: 日志器 - cancel_token: 取消令牌 - """ - - def __init__( - self, - *, - peer, - plugin_id: str, - request_id: str | None = None, - cancel_token: CancelToken | None = None, - logger: Any | None = None, - source_event_payload: dict[str, Any] | None = None, - ) -> None: - """初始化上下文。 - - Args: - peer: 协议对等端实例 - plugin_id: 当前插件 ID - cancel_token: 取消令牌,None 时创建新令牌 - logger: 日志器,None 时使用默认 logger 并绑定 plugin_id - """ - proxy = CapabilityProxy( - peer, - caller_plugin_id=plugin_id, - request_scope_id=request_id, - ) - if isinstance(logger, PluginLogger): - bound_logger = logger - else: - bound_logger = logger or base_logger.bind(plugin_id=plugin_id) - self._proxy = proxy - self.peer = peer - self.llm = LLMClient(proxy) - self.memory = MemoryClient(proxy) - self.db = DBClient(proxy) - self.files = FileServiceClient(proxy) - self.platform = PlatformClient(proxy) - self.providers = ProviderClient(proxy) - self.provider_manager = ProviderManagerClient( - proxy, - plugin_id=plugin_id, - logger=bound_logger, - ) - self.personas = PersonaManagerClient(proxy) - self.conversations = ConversationManagerClient(proxy) - self.kbs = KnowledgeBaseManagerClient(proxy) - self.message_history = MessageHistoryManagerClient(proxy) - self.http = HTTPClient(proxy) - self.metadata = MetadataClient(proxy, plugin_id) - self.mcp = MCPManagerClient(proxy) - self.registry = RegistryClient(proxy) - self.skills = SkillClient(proxy) - self.session_plugins = SessionPluginManager(proxy) - self.session_services = SessionServiceManager(proxy) - self.persona_manager = self.personas - self.conversation_manager = self.conversations - self.kb_manager = self.kbs - self.message_history_manager = self.message_history - self.mcp_manager = self.mcp - self._llm_tool_manager = LLMToolManager(proxy) - self.plugin_id = plugin_id - self.logger: PluginLogger = ( - bound_logger - if isinstance(bound_logger, PluginLogger) - else PluginLogger(plugin_id=plugin_id, logger=bound_logger) - ) - self.cancel_token = cancel_token or CancelToken() - self.request_id = request_id - self._source_event_payload = ( - dict(source_event_payload) if isinstance(source_event_payload, dict) else {} - ) - - async def get_data_dir(self) -> Path: - """Return the plugin-scoped data directory path.""" - output = await self._proxy.call("system.get_data_dir", {}) - return Path(str(output.get("path", ""))) - - async def _register_file_url( - self, - path: str, - timeout: float | None = None, - ) -> str: - return await self.files._register_file_url(path, timeout=timeout) - - async def text_to_image( - self, - text: str, - *, - return_url: bool = True, - ) -> str: - """Render plain text into an image using the host renderer.""" - output = await self._proxy.call( - "system.text_to_image", - {"text": text, "return_url": return_url}, - ) - return str(output.get("result", "")) - - async def html_render( - self, - tmpl: str, - data: dict[str, Any], - *, - return_url: bool = True, - options: dict[str, Any] | None = None, - ) -> str: - """Render an HTML template using the host renderer.""" - output = await self._proxy.call( - "system.html_render", - { - "tmpl": tmpl, - "data": dict(data), - "return_url": return_url, - "options": options, - }, - ) - return str(output.get("result", "")) - - async def get_using_provider(self, umo: str | None = None) -> ProviderMeta | None: - return await self.providers.get_using_chat(umo) - - async def get_current_chat_provider_id(self, umo: str | None = None) -> str | None: - output = await self._proxy.call( - "provider.get_current_chat_provider_id", - {"umo": umo}, - ) - value = output.get("provider_id") - return str(value) if value else None - - async def get_all_providers(self) -> list[ProviderMeta]: - return await self.providers.list_all() - - async def get_all_tts_providers(self) -> list[ProviderMeta]: - return await self.providers.list_tts() - - async def get_all_stt_providers(self) -> list[ProviderMeta]: - return await self.providers.list_stt() - - async def get_all_embedding_providers(self) -> list[ProviderMeta]: - return await self.providers.list_embedding() - - async def get_all_rerank_providers(self) -> list[ProviderMeta]: - return await self.providers.list_rerank() - - async def get_using_tts_provider( - self, umo: str | None = None - ) -> ProviderMeta | None: - provider = await self.providers.get_using_tts(umo) - return provider.meta() if provider is not None else None - - async def get_using_stt_provider( - self, umo: str | None = None - ) -> ProviderMeta | None: - provider = await self.providers.get_using_stt(umo) - return provider.meta() if provider is not None else None - - async def send_message( - self, - session: str | MessageSession, - content: PlatformCompatContent, - ) -> dict[str, Any]: - return await self.platform.send_by_session(session, content) - - async def send_message_by_id( - self, - type: str, - id: str, - content: PlatformCompatContent, - *, - platform: str, - ) -> dict[str, Any]: - platform_payload = await self._resolve_platform_target(platform) - return await self.platform.send_by_id( - str(platform_payload.get("id", "")), - str(id), - content, - message_type=self._normalize_compat_message_type(type), - ) - - @staticmethod - def _normalize_compat_message_type(value: str) -> str: - normalized = str(value).strip().lower() - if normalized in {"groupmessage", "group_message", "group"}: - return "group" - if normalized in { - "privatemessage", - "private_message", - "private", - "friendmessage", - "friend_message", - "friend", - }: - return "private" - if not normalized: - raise AstrBotError.invalid_input("send_message_by_id requires type") - return normalized - - async def _resolve_platform_target(self, platform: str) -> dict[str, Any]: - target = str(platform).strip() - if not target: - raise AstrBotError.invalid_input( - "send_message_by_id requires explicit platform" - ) - instances = await self._list_platform_instances() - id_matches = [ - item for item in instances if str(item.get("id", "")).strip() == target - ] - if len(id_matches) == 1: - return id_matches[0] - normalized_target = target.lower() - alias_matches = [ - item - for item in instances - if str(item.get("type", "")).strip().lower() == normalized_target - or str(item.get("name", "")).strip().lower() == normalized_target - ] - if len(alias_matches) == 1: - return alias_matches[0] - if len(alias_matches) > 1: - raise AstrBotError.invalid_input( - f"send_message_by_id platform '{target}' is ambiguous" - ) - raise AstrBotError.invalid_input( - f"send_message_by_id cannot resolve platform '{target}'" - ) - - def get_llm_tool_manager(self) -> LLMToolManager: - return self._llm_tool_manager - - async def activate_llm_tool(self, name: str) -> bool: - return await self._llm_tool_manager.activate(name) - - async def deactivate_llm_tool(self, name: str) -> bool: - return await self._llm_tool_manager.deactivate(name) - - async def add_llm_tools(self, *tools: LLMToolSpec) -> list[str]: - return await self._llm_tool_manager.add(*tools) - - async def register_llm_tool( - self, - name: str, - parameters_schema: dict[str, Any], - desc: str, - func_obj: Callable[..., Any] | Callable[..., Awaitable[Any]], - *, - active: bool = True, - ) -> list[str]: - if not callable(func_obj): - raise TypeError("register_llm_tool requires a callable func_obj") - tool_name = str(name).strip() - if not tool_name: - raise AstrBotError.invalid_input("register_llm_tool requires name") - if not isinstance(parameters_schema, dict): - raise TypeError("register_llm_tool requires parameters_schema dict") - - handler_ref = f"__dynamic_llm_tool__:{tool_name}" - tool_spec = LLMToolSpec.create( - name=tool_name, - description=str(desc), - parameters_schema=dict(parameters_schema), - handler_ref=handler_ref, - active=bool(active), - ) - owner = getattr(func_obj, "__self__", None) or current_star_instance() - dispatcher = getattr(self.peer, "_sdk_capability_dispatcher", None) - if dispatcher is not None and hasattr(dispatcher, "add_dynamic_llm_tool"): - dispatcher.add_dynamic_llm_tool( - plugin_id=self.plugin_id, - spec=tool_spec, - callable_obj=func_obj, - owner=owner, - ) - try: - return await self._llm_tool_manager.add(tool_spec) - except Exception: - if dispatcher is not None and hasattr(dispatcher, "remove_llm_tool"): - dispatcher.remove_llm_tool(self.plugin_id, tool_name) - raise - - async def unregister_llm_tool(self, name: str) -> bool: - removed = await self._llm_tool_manager.remove(str(name)) - dispatcher = getattr(self.peer, "_sdk_capability_dispatcher", None) - if dispatcher is not None and hasattr(dispatcher, "remove_llm_tool"): - dispatcher.remove_llm_tool(self.plugin_id, str(name)) - return removed - - async def register_skill( - self, - *, - name: str, - path: str | Path, - description: str = "", - ) -> SkillRegistration: - return await self.skills.register( - name=name, - path=str(path), - description=description, - ) - - async def unregister_skill(self, name: str) -> bool: - return await self.skills.unregister(name) - - async def tool_loop_agent( - self, - request: ProviderRequest | None = None, - **kwargs: Any, - ) -> LLMResponse: - provider_request = request or ProviderRequest() - if kwargs: - merged = provider_request.model_dump() - merged.update(kwargs) - provider_request = ProviderRequest.model_validate(merged) - payload = provider_request.to_payload() - target_payload = self._source_event_payload.get("target") - if isinstance(target_payload, dict): - # Preserve the original message target so core can recover the - # dispatch token for message-bound tool loop execution. - payload["target"] = dict(target_payload) - output = await self._proxy.call("agent.tool_loop.run", payload) - return LLMResponse.model_validate(output) - - def _source_event_type(self) -> str: - event_type = self._source_event_payload.get("event_type") - if isinstance(event_type, str) and event_type.strip(): - return event_type.strip() - fallback_type = self._source_event_payload.get("type") - if isinstance(fallback_type, str) and fallback_type.strip(): - return fallback_type.strip() - raw_payload = self._source_event_payload.get("raw") - if isinstance(raw_payload, dict): - raw_event_type = raw_payload.get("event_type") - if isinstance(raw_event_type, str) and raw_event_type.strip(): - return raw_event_type.strip() - return "" - - async def register_commands( - self, - command_name: str, - handler_full_name: str, - *, - desc: str = "", - priority: int = 0, - use_regex: bool = False, - ignore_prefix: bool = False, - ) -> None: - source_event_type = self._source_event_type() - if source_event_type not in {"astrbot_loaded", "platform_loaded"}: - raise AstrBotError.invalid_input( - "register_commands is only available in astrbot_loaded/platform_loaded events" - ) - if ignore_prefix: - raise AstrBotError.invalid_input( - "register_commands(ignore_prefix=True) is unsupported in SDK runtime" - ) - if isinstance(priority, bool) or not isinstance(priority, int): - raise AstrBotError.invalid_input( - "register_commands priority must be an integer" - ) - await self._proxy.call( - "registry.command.register", - { - "command_name": str(command_name), - "handler_full_name": str(handler_full_name), - "source_event_type": source_event_type, - "desc": str(desc), - "priority": priority, - "use_regex": bool(use_regex), - "ignore_prefix": False, - }, - ) - - async def register_task( - self, - task: Awaitable[Any], - desc: str, - ) -> asyncio.Task[Any]: - """Register a background task owned by the current SDK context. - - This is the recommended way to launch follow-up work that should outlive - the current handler dispatch, including `session_waiter(...)` flows. - Directly awaiting a waiter inside the current handler keeps the original - dispatch open until the next message arrives. - - Example: - await event.reply("请输入用户名:") - await ctx.register_task( - self.collect_username(event), - "waiter:collect_username", - ) - """ - task_desc = str(desc) - - async def _wrap_future(future: asyncio.Future[Any]) -> Any: - return await future - - if isinstance(task, asyncio.Task): - background_task = task - elif asyncio.isfuture(task): - background_task = asyncio.create_task(_wrap_future(task)) - elif asyncio.iscoroutine(task): - background_task = asyncio.create_task(task) - else: - raise TypeError("register_task requires an awaitable task object") - - _mark_session_waiter_background_task(background_task) - - def _on_done(done_task: asyncio.Task[Any]) -> None: - _unmark_session_waiter_background_task(done_task) - if done_task.cancelled(): - debug_logger = getattr(self.logger, "debug", None) - if callable(debug_logger): - debug_logger( - "SDK background task cancelled: plugin_id={} desc={}", - self.plugin_id, - task_desc, - ) - return - try: - done_task.result() - except Exception: - exception_logger = getattr(self.logger, "exception", None) - if callable(exception_logger): - exception_logger( - "SDK background task failed: plugin_id={} desc={}", - self.plugin_id, - task_desc, - ) - - background_task.add_done_callback(_on_done) - return background_task - - async def _list_platform_instances(self) -> list[dict[str, Any]]: - output = await self._proxy.call("platform.list_instances", {}) - items = output.get("platforms") - if not isinstance(items, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in items: - if not isinstance(item, dict): - continue - platform_id = str(item.get("id", "")).strip() - platform_type = str(item.get("type", "")).strip() - if not platform_id or not platform_type: - continue - normalized.append( - { - "id": platform_id, - "name": str(item.get("name", platform_id)), - "type": platform_type, - "status": PlatformStatus.from_value(item.get("status")), - } - ) - return normalized - - def _build_platform_facade( - self, - platform_payload: dict[str, Any], - ) -> PlatformCompatFacade: - return PlatformCompatFacade( - _ctx=self, - id=str(platform_payload.get("id", "")), - name=str(platform_payload.get("name", "")), - type=str(platform_payload.get("type", "")), - status=PlatformStatus.from_value(platform_payload.get("status")), - ) - - async def get_platform(self, platform_type: str) -> PlatformCompatFacade | None: - target_type = str(platform_type).strip().lower() - if not target_type: - return None - for item in await self._list_platform_instances(): - if str(item.get("type", "")).strip().lower() == target_type: - return self._build_platform_facade(item) - return None - - async def get_platform_inst(self, platform_id: str) -> PlatformCompatFacade | None: - target_id = str(platform_id).strip() - if not target_id: - return None - for item in await self._list_platform_instances(): - if str(item.get("id", "")).strip() == target_id: - return self._build_platform_facade(item) - return None diff --git a/astrbot-sdk/src/astrbot_sdk/conversation.py b/astrbot-sdk/src/astrbot_sdk/conversation.py deleted file mode 100644 index a39c3fece..000000000 --- a/astrbot-sdk/src/astrbot_sdk/conversation.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from enum import Enum -from typing import Any - -from .context import Context -from .events import MessageEvent -from .message.components import BaseMessageComponent -from .message.result import MessageChain -from .session_waiter import SessionWaiterManager - -DEFAULT_BUSY_MESSAGE = "当前会话已有进行中的交互,请先完成后再试。" - - -class ConversationState(str, Enum): - ACTIVE = "active" - REJECTED_BUSY = "rejected_busy" - REPLACED = "replaced" - TIMEOUT = "timeout" - COMPLETED = "completed" - CANCELLED = "cancelled" - - -class ConversationReplaced(RuntimeError): - pass - - -class ConversationClosed(RuntimeError): - pass - - -@dataclass(slots=True) -class ConversationSession: - ctx: Context - event: MessageEvent - waiter_manager: SessionWaiterManager - timeout: int - state: ConversationState = ConversationState.ACTIVE - _owner_task: asyncio.Task[Any] | None = None - - def __post_init__(self) -> None: - if self.state != ConversationState.ACTIVE: - self.state = ConversationState.ACTIVE - - def bind_owner_task(self, task: asyncio.Task[Any]) -> None: - self._owner_task = task - - @property - def session_key(self) -> str: - return self.event.unified_msg_origin - - @property - def active(self) -> bool: - return self.state == ConversationState.ACTIVE - - async def ask(self, prompt: str, timeout: int | None = None) -> MessageEvent: - self._ensure_usable("ask") - if prompt: - await self.reply(prompt) - try: - return await self.waiter_manager.wait_for_event( - event=self.event, - timeout=timeout or self.timeout, - record_history_chains=False, - ) - except asyncio.TimeoutError: - self.close(ConversationState.TIMEOUT) - raise - except asyncio.CancelledError as exc: - if self.state == ConversationState.REPLACED: - raise ConversationReplaced( - "conversation replaced by a newer session" - ) from exc - self.close(ConversationState.CANCELLED) - raise - - async def reply(self, text: str) -> None: - self._ensure_usable("reply") - await self.event.reply(text) - - async def reply_chain( - self, - chain: MessageChain | list[BaseMessageComponent] | list[dict[str, Any]], - ) -> None: - self._ensure_usable("reply_chain") - await self.event.reply_chain(chain) - - async def send_message( - self, - content: str | MessageChain | list[BaseMessageComponent] | list[dict[str, Any]], - ) -> dict[str, Any]: - self._ensure_usable("send_message") - return await self.ctx.platform.send_by_session(self.event.session_id, content) - - def end(self) -> None: - self.close(ConversationState.COMPLETED) - - def mark_replaced(self) -> None: - self.close(ConversationState.REPLACED) - - def close(self, state: ConversationState) -> None: - if self.state != ConversationState.ACTIVE and state == self.state: - return - if ( - self.state != ConversationState.ACTIVE - and state != ConversationState.REPLACED - ): - return - self.state = state - - def _ensure_usable(self, action: str) -> None: - if ( - self._owner_task is not None - and asyncio.current_task() is not self._owner_task - ): - raise ConversationClosed( - f"ConversationSession cannot be used outside its owner task during {action}" - ) - if not self.active: - raise ConversationClosed( - f"ConversationSession is already closed ({self.state.value}) during {action}" - ) - - -__all__ = [ - "ConversationClosed", - "ConversationReplaced", - "ConversationSession", - "ConversationState", - "DEFAULT_BUSY_MESSAGE", -] diff --git a/astrbot-sdk/src/astrbot_sdk/decorators.py b/astrbot-sdk/src/astrbot_sdk/decorators.py deleted file mode 100644 index 08132ab04..000000000 --- a/astrbot-sdk/src/astrbot_sdk/decorators.py +++ /dev/null @@ -1,928 +0,0 @@ -"""v4 原生装饰器。 - -提供声明式的方法来注册 handler 和 capability。 -装饰器会在方法上附加元数据,由 Star.__init_subclass__ 自动收集。 - -触发器装饰器: - - @on_command: 命令触发器 - - @on_message: 消息触发器(关键词/正则) - - @on_event: 事件触发器 - - @on_schedule: 定时任务触发器 - - @conversation_command: 带会话生命周期的命令触发器 - -权限与过滤装饰器: - - @require_admin / @admin_only: 管理员权限标记 - - @platforms: 限定平台 - - @group_only / @private_only: 群聊/私聊限定 - - @message_types: 消息类型过滤 - -限流装饰器: - - @rate_limit: 滑动窗口限流 - - @cooldown: 冷却时间 - -优先级装饰器: - - @priority: 设置执行优先级 - -能力导出装饰器: - - @provide_capability: 声明对外暴露的能力 - - @register_llm_tool: 注册 LLM 工具 - - @register_agent: 注册 Agent - -Example: - class MyPlugin(Star): - @on_command("hello", aliases=["hi"]) - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") - - @on_message(keywords=["help"]) - async def help(self, event: MessageEvent, ctx: Context): - await event.reply("Help info...") - - @provide_capability("my_plugin.calculate", description="计算") - async def calculate(self, payload: dict, ctx: Context): - return {"result": payload["x"] * 2} -""" - -from __future__ import annotations - -import inspect -import typing -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any, Literal, cast - -from pydantic import BaseModel - -from ._internal.typing_utils import unwrap_optional -from .llm.agents import AgentSpec, BaseAgentRunner -from .llm.entities import LLMToolSpec -from .protocol.descriptors import ( - RESERVED_CAPABILITY_PREFIXES, - CapabilityDescriptor, - CommandRouteSpec, - CommandTrigger, - EventTrigger, - FilterSpec, - MessageTrigger, - MessageTypeFilterSpec, - Permissions, - PlatformFilterSpec, - ScheduleTrigger, -) - -HandlerCallable = Callable[..., Any] -HANDLER_META_ATTR = "__astrbot_handler_meta__" -CAPABILITY_META_ATTR = "__astrbot_capability_meta__" -LLM_TOOL_META_ATTR = "__astrbot_llm_tool_meta__" -AGENT_META_ATTR = "__astrbot_agent_meta__" - -LimiterScope = Literal["session", "user", "group", "global"] -LimiterBehavior = Literal["hint", "silent", "error"] -ConversationMode = Literal["replace", "reject"] - - -@dataclass(slots=True) -class LimiterMeta: - kind: Literal["rate_limit", "cooldown"] - limit: int - window: float - scope: LimiterScope = "session" - behavior: LimiterBehavior = "hint" - message: str | None = None - - -@dataclass(slots=True) -class ConversationMeta: - timeout: int = 60 - mode: ConversationMode = "replace" - busy_message: str | None = None - grace_period: float = 1.0 - - -@dataclass(slots=True) -class HandlerMeta: - """Handler 元数据。 - - 存储在方法上的 __astrbot_handler_meta__ 属性中。 - - Attributes: - trigger: 触发器(命令/消息/事件/定时) - kind: handler 类型标识 - contract: 契约类型(可选) - priority: 执行优先级(数值越大越先执行) - permissions: 权限要求 - """ - - trigger: CommandTrigger | MessageTrigger | EventTrigger | ScheduleTrigger | None = ( - None - ) - kind: str = "handler" - contract: str | None = None - description: str | None = None - priority: int = 0 - permissions: Permissions = field(default_factory=Permissions) - filters: list[FilterSpec] = field(default_factory=list) - local_filters: list[Any] = field(default_factory=list) - command_route: CommandRouteSpec | None = None - limiter: LimiterMeta | None = None - conversation: ConversationMeta | None = None - decorator_sources: dict[str, str] = field(default_factory=dict) - - -@dataclass(slots=True) -class CapabilityMeta: - """Capability 元数据。 - - 存储在方法上的 __astrbot_capability_meta__ 属性中。 - - Attributes: - descriptor: 能力描述符 - """ - - descriptor: CapabilityDescriptor - - -@dataclass(slots=True) -class LLMToolMeta: - spec: LLMToolSpec - - -@dataclass(slots=True) -class AgentMeta: - spec: AgentSpec - - -def _get_or_create_meta(func: HandlerCallable) -> HandlerMeta: - """获取或创建 handler 元数据。""" - meta = getattr(func, HANDLER_META_ATTR, None) - if meta is None: - meta = HandlerMeta() - setattr(func, HANDLER_META_ATTR, meta) - return meta - - -def get_handler_meta(func: HandlerCallable) -> HandlerMeta | None: - """获取方法的 handler 元数据。 - - Args: - func: 要检查的方法 - - Returns: - HandlerMeta 实例,如果没有则返回 None - """ - return getattr(func, HANDLER_META_ATTR, None) - - -def get_capability_meta(func: HandlerCallable) -> CapabilityMeta | None: - """获取方法的 capability 元数据。 - - Args: - func: 要检查的方法 - - Returns: - CapabilityMeta 实例,如果没有则返回 None - """ - return getattr(func, CAPABILITY_META_ATTR, None) - - -def get_llm_tool_meta(func: HandlerCallable) -> LLMToolMeta | None: - return getattr(func, LLM_TOOL_META_ATTR, None) - - -def get_agent_meta(obj: Any) -> AgentMeta | None: - return getattr(obj, AGENT_META_ATTR, None) - - -def _replace_filter(meta: HandlerMeta, spec: FilterSpec) -> None: - kind = getattr(spec, "kind", None) - meta.filters = [ - item for item in meta.filters if getattr(item, "kind", None) != kind - ] - meta.filters.append(spec) - - -def _has_filter_kind(meta: HandlerMeta, kind: str) -> bool: - return any(getattr(item, "kind", None) == kind for item in meta.filters) - - -def _set_platform_filter( - meta: HandlerMeta, - values: list[str], - *, - source: str, -) -> None: - normalized = [ - value for value in dict.fromkeys(str(item).strip() for item in values) if value - ] - if not normalized: - return - existing = meta.decorator_sources.get("platforms") - if existing is not None and existing != source: - raise ValueError("platforms(...) 不能与 on_message(platforms=...) 混用") - if existing is None and _has_filter_kind(meta, "platform"): - raise ValueError("platforms(...) 不能与已有平台过滤器混用") - meta.decorator_sources["platforms"] = source - _replace_filter(meta, PlatformFilterSpec(platforms=normalized)) - - -def _set_message_type_filter( - meta: HandlerMeta, - values: list[str], - *, - source: str, -) -> None: - normalized = [ - value - for value in dict.fromkeys(str(item).strip().lower() for item in values) - if value - ] - if not normalized: - return - existing = meta.decorator_sources.get("message_types") - if existing is not None and existing != source: - raise ValueError( - "group_only()/private_only()/message_types(...) 不能与已有消息类型约束混用" - ) - if existing is None and _has_filter_kind(meta, "message_type"): - raise ValueError( - "group_only()/private_only()/message_types(...) 不能与已有消息类型过滤器混用" - ) - meta.decorator_sources["message_types"] = source - _replace_filter(meta, MessageTypeFilterSpec(message_types=normalized)) - - -def _validate_message_trigger_compatibility(meta: HandlerMeta) -> None: - if meta.limiter is None or meta.trigger is None: - return - trigger_type = getattr(meta.trigger, "type", None) - if trigger_type not in {"command", "message"}: - raise ValueError( - "rate_limit(...) 和 cooldown(...) 只适用于 on_command/on_message" - ) - - -def _normalize_description(description: str | None) -> str | None: - if description is None: - return None - text = str(description).strip() - return text or None - - -def _validate_limiter_args( - *, - kind: str, - limit: int, - window: float, - scope: LimiterScope, - behavior: LimiterBehavior, -) -> None: - if isinstance(limit, bool) or int(limit) <= 0: - raise ValueError(f"{kind} requires a positive limit") - if float(window) <= 0: - raise ValueError(f"{kind} requires a positive window") - if scope not in {"session", "user", "group", "global"}: - raise ValueError(f"unsupported limiter scope: {scope}") - if behavior not in {"hint", "silent", "error"}: - raise ValueError(f"unsupported limiter behavior: {behavior}") - - -def _set_limiter( - func: HandlerCallable, - limiter: LimiterMeta, -) -> HandlerCallable: - meta = _get_or_create_meta(func) - if meta.limiter is not None: - raise ValueError("rate_limit(...) 和 cooldown(...) 不能叠加在同一个 handler 上") - meta.limiter = limiter - _validate_message_trigger_compatibility(meta) - return func - - -def _model_to_schema( - model: type[BaseModel] | None, - *, - label: str, -) -> dict[str, Any] | None: - """将 pydantic 模型转换为 JSON Schema。 - - Args: - model: pydantic BaseModel 子类 - label: 错误消息中的字段名 - - Returns: - JSON Schema 字典,如果 model 为 None 则返回 None - - Raises: - TypeError: 如果 model 不是 BaseModel 子类 - """ - if model is None: - return None - if not isinstance(model, type) or not issubclass(model, BaseModel): - raise TypeError(f"{label} 必须是 pydantic BaseModel 子类") - return cast(dict[str, Any], model.model_json_schema()) - - -def on_command( - command: str | typing.Sequence[str], - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - """注册命令处理方法。 - - 当用户发送指定命令时触发。命令格式为 `/{command}` 或直接 `{command}`, - 取决于平台配置。 - - Args: - command: 命令名称(不包含前缀符) - aliases: 命令别名列表 - description: 命令描述,用于帮助信息 - - Returns: - 装饰器函数 - - Example: - @on_command("echo", aliases=["repeat"], description="重复消息") - async def echo(self, event: MessageEvent, ctx: Context): - await event.reply(event.text) - """ - - commands = ( - [str(command).strip()] - if isinstance(command, str) - else [str(item).strip() for item in command] - ) - commands = [item for item in commands if item] - if not commands: - raise ValueError("on_command requires at least one non-empty command name") - canonical = commands[0] - merged_aliases: list[str] = [ - item - for item in dict.fromkeys([*commands[1:], *(aliases or [])]) - if isinstance(item, str) and item and item != canonical - ] - - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - normalized_description = _normalize_description(description) - meta.trigger = CommandTrigger( - command=canonical, - aliases=merged_aliases, - description=normalized_description, - ) - meta.description = normalized_description - _validate_message_trigger_compatibility(meta) - return func - - return decorator - - -def on_message( - *, - regex: str | None = None, - keywords: list[str] | None = None, - platforms: list[str] | None = None, - message_types: list[str] | None = None, - description: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - """注册消息处理方法。 - - 当消息匹配指定条件时触发。支持正则表达式或关键词匹配。 - - Args: - regex: 正则表达式模式 - keywords: 关键词列表(任一匹配即可) - platforms: 限定平台列表(如 ["qq", "wechat"]) - - Returns: - 装饰器函数 - - Note: - regex 和 keywords 至少提供一个 - - Example: - @on_message(keywords=["help", "帮助"]) - async def help(self, event: MessageEvent, ctx: Context): - await event.reply("帮助信息") - - @on_message(regex=r"\\d+") # 匹配数字 - async def number_handler(self, event: MessageEvent, ctx: Context): - await event.reply("收到了数字") - """ - - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - meta.trigger = MessageTrigger( - regex=regex, - keywords=keywords or [], - platforms=platforms or [], - message_types=message_types or [], - ) - meta.description = _normalize_description(description) - if platforms: - _set_platform_filter(meta, list(platforms), source="trigger.platforms") - if message_types: - _set_message_type_filter( - meta, - list(message_types), - source="trigger.message_types", - ) - _validate_message_trigger_compatibility(meta) - return func - - return decorator - - -def append_filter_meta( - func: HandlerCallable, - *, - specs: list[FilterSpec] | None = None, - local_bindings: list[Any] | None = None, -) -> HandlerCallable: - """追加过滤器元数据。""" - meta = _get_or_create_meta(func) - if specs: - meta.filters.extend(specs) - if local_bindings: - meta.local_filters.extend(local_bindings) - return func - - -def set_command_route_meta( - func: HandlerCallable, - route: CommandRouteSpec, -) -> HandlerCallable: - """设置命令路由元数据。""" - meta = _get_or_create_meta(func) - meta.command_route = route - return func - - -def on_event( - event_type: str, - *, - description: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - """注册事件处理方法。 - - 当特定类型的事件发生时触发。用于处理非消息类型的事件, - 如群成员变动、好友请求等。 - - Args: - event_type: 事件类型标识 - - Returns: - 装饰器函数 - - Example: - @on_event("group_member_join") - async def on_join(self, event, ctx): - await ctx.platform.send(event.group_id, "欢迎新人!") - """ - - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - meta.trigger = EventTrigger(event_type=event_type) - meta.description = _normalize_description(description) - _validate_message_trigger_compatibility(meta) - return func - - return decorator - - -def on_schedule( - *, - cron: str | None = None, - interval_seconds: int | None = None, - description: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - """注册定时任务方法。 - - 按指定的时间计划定期执行。 - - Args: - cron: cron 表达式(如 "0 8 * * *" 表示每天 8:00) - interval_seconds: 执行间隔(秒) - - Returns: - 装饰器函数 - - Note: - cron 和 interval_seconds 至少提供一个 - - Example: - @on_schedule(cron="0 8 * * *") # 每天 8:00 - async def morning_greeting(self, ctx): - await ctx.platform.send("group_123", "早上好!") - - @on_schedule(interval_seconds=3600) # 每小时 - async def hourly_check(self, ctx): - pass - """ - - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - meta.trigger = ScheduleTrigger(cron=cron, interval_seconds=interval_seconds) - meta.description = _normalize_description(description) - _validate_message_trigger_compatibility(meta) - return func - - return decorator - - -def require_admin(func: HandlerCallable) -> HandlerCallable: - """标记 handler 需要管理员权限。 - - 当用户不是管理员时,handler 将不会被调用。 - - Args: - func: 要标记的方法 - - Returns: - 标记后的方法 - - Example: - @on_command("admin") - @require_admin - async def admin_only(self, event: MessageEvent, ctx: Context): - await event.reply("管理员命令执行成功") - """ - meta = _get_or_create_meta(func) - meta.permissions.require_admin = True - return func - - -def admin_only(func: HandlerCallable) -> HandlerCallable: - return require_admin(func) - - -def platforms(*names: str) -> Callable[[HandlerCallable], HandlerCallable]: - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - _set_platform_filter(meta, list(names), source="decorator.platforms") - return func - - return decorator - - -def message_types(*types: str) -> Callable[[HandlerCallable], HandlerCallable]: - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - _set_message_type_filter( - meta, - list(types), - source="decorator.message_types", - ) - return func - - return decorator - - -def group_only() -> Callable[[HandlerCallable], HandlerCallable]: - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - _set_message_type_filter(meta, ["group"], source="decorator.group_only") - return func - - return decorator - - -def private_only() -> Callable[[HandlerCallable], HandlerCallable]: - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - _set_message_type_filter(meta, ["private"], source="decorator.private_only") - return func - - return decorator - - -def priority(value: int) -> Callable[[HandlerCallable], HandlerCallable]: - if isinstance(value, bool) or not isinstance(value, int): - raise ValueError("priority(...) requires an integer") - - def decorator(func: HandlerCallable) -> HandlerCallable: - meta = _get_or_create_meta(func) - meta.priority = value - return func - - return decorator - - -def rate_limit( - limit: int, - window: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - _validate_limiter_args( - kind="rate_limit", - limit=limit, - window=window, - scope=scope, - behavior=behavior, - ) - - def decorator(func: HandlerCallable) -> HandlerCallable: - return _set_limiter( - func, - LimiterMeta( - kind="rate_limit", - limit=int(limit), - window=float(window), - scope=scope, - behavior=behavior, - message=message, - ), - ) - - return decorator - - -def cooldown( - seconds: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable]: - _validate_limiter_args( - kind="cooldown", - limit=1, - window=seconds, - scope=scope, - behavior=behavior, - ) - - def decorator(func: HandlerCallable) -> HandlerCallable: - return _set_limiter( - func, - LimiterMeta( - kind="cooldown", - limit=1, - window=float(seconds), - scope=scope, - behavior=behavior, - message=message, - ), - ) - - return decorator - - -def conversation_command( - command: str | typing.Sequence[str], - *, - aliases: list[str] | None = None, - description: str | None = None, - timeout: int = 60, - mode: ConversationMode = "replace", - busy_message: str | None = None, - grace_period: float = 1.0, -) -> Callable[[HandlerCallable], HandlerCallable]: - """注册带会话生命周期的命令处理方法。 - - 在 ``on_command`` 基础上附加会话元数据,支持超时、并发策略和宽限期控制。 - - Args: - command: 命令名称或序列(首项为正式名,其余视为别名) - aliases: 额外别名列表 - description: 命令描述 - timeout: 会话超时时间(秒),必须为正整数 - mode: 会话冲突时的行为: - - ``"replace"``: 替换当前会话 - - ``"reject"``: 拒绝新请求 - busy_message: 拒绝新请求时的提示消息 - grace_period: 宽限期(秒),用于会话生命周期处理 - - Returns: - 装饰器函数 - - Raises: - ValueError: mode 不合法、timeout 非正整数或 grace_period 非正数 - - Example: - @conversation_command("chat", timeout=120, mode="reject", busy_message="请稍后再试") - async def chat(self, event: MessageEvent, ctx: Context): - await event.reply("开始对话...") - """ - if mode not in {"replace", "reject"}: - raise ValueError("conversation_command mode must be 'replace' or 'reject'") - # bool 是 int 子类,需单独排除 - if isinstance(timeout, bool) or int(timeout) <= 0: - raise ValueError("conversation_command timeout must be a positive integer") - if float(grace_period) <= 0: - raise ValueError("conversation_command grace_period must be positive") - - command_decorator = on_command( - command, - aliases=aliases, - description=description, - ) - - def decorator(func: HandlerCallable) -> HandlerCallable: - decorated = command_decorator(func) - meta = _get_or_create_meta(decorated) - meta.conversation = ConversationMeta( - timeout=int(timeout), - mode=mode, - busy_message=busy_message, - grace_period=float(grace_period), - ) - return decorated - - return decorator - - -def provide_capability( - name: str, - *, - description: str, - input_schema: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, - input_model: type[BaseModel] | None = None, - output_model: type[BaseModel] | None = None, - supports_stream: bool = False, - cancelable: bool = False, -) -> Callable[[HandlerCallable], HandlerCallable]: - """声明插件对外暴露的 capability。 - - 允许其他插件或 Core 通过 capability 名称调用此方法。 - 支持使用 JSON Schema 或 pydantic 模型定义输入输出。 - - Args: - name: capability 名称(不能使用保留命名空间) - description: 能力描述 - input_schema: 输入 JSON Schema - output_schema: 输出 JSON Schema - input_model: 输入 pydantic 模型(与 input_schema 二选一) - output_model: 输出 pydantic 模型(与 output_schema 二选一) - supports_stream: 是否支持流式输出 - cancelable: 是否可取消 - - Returns: - 装饰器函数 - - Raises: - ValueError: 如果使用保留命名空间,或同时提供 schema 和 model - - Example: - @provide_capability( - "my_plugin.calculate", - description="执行计算", - input_model=CalculateInput, - output_model=CalculateOutput, - ) - async def calculate(self, payload: dict, ctx: Context): - return {"result": payload["x"] * 2} - """ - - def decorator(func: HandlerCallable) -> HandlerCallable: - if name.startswith(RESERVED_CAPABILITY_PREFIXES): - raise ValueError(f"保留 capability 命名空间不能用于插件导出:{name}") - if input_schema is not None and input_model is not None: - raise ValueError("input_schema 和 input_model 不能同时提供") - if output_schema is not None and output_model is not None: - raise ValueError("output_schema 和 output_model 不能同时提供") - descriptor = CapabilityDescriptor( - name=name, - description=description, - input_schema=( - input_schema - if input_schema is not None - else _model_to_schema(input_model, label="input_model") - ), - output_schema=( - output_schema - if output_schema is not None - else _model_to_schema(output_model, label="output_model") - ), - supports_stream=supports_stream, - cancelable=cancelable, - ) - setattr(func, CAPABILITY_META_ATTR, CapabilityMeta(descriptor=descriptor)) - return func - - return decorator - - -def _annotation_to_schema(annotation: Any) -> dict[str, Any]: - normalized, _is_optional = unwrap_optional(annotation) - origin = typing.get_origin(normalized) - if normalized is str: - return {"type": "string"} - if normalized is int: - return {"type": "integer"} - if normalized is float: - return {"type": "number"} - if normalized is bool: - return {"type": "boolean"} - if normalized is dict or origin is dict: - return {"type": "object"} - if normalized is list or origin is list: - args = typing.get_args(normalized) - item_schema = _annotation_to_schema(args[0]) if args else {} - return {"type": "array", "items": item_schema} - return {"type": "string"} - - -def _callable_parameters_schema(func: HandlerCallable) -> dict[str, Any]: - signature = inspect.signature(func) - type_hints: dict[str, Any] = {} - try: - type_hints = typing.get_type_hints(func) - except Exception: - type_hints = {} - - properties: dict[str, Any] = {} - required: list[str] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - if parameter.name == "self": - continue - annotation = type_hints.get(parameter.name) - normalized, _is_optional = unwrap_optional(annotation) - if parameter.name in {"event", "ctx", "context"}: - continue - properties[parameter.name] = _annotation_to_schema(normalized) - if parameter.default is inspect.Parameter.empty and not _is_optional: - required.append(parameter.name) - schema: dict[str, Any] = {"type": "object", "properties": properties} - if required: - schema["required"] = required - return schema - - -def register_llm_tool( - name: str | None = None, - *, - description: str | None = None, - parameters_schema: dict[str, Any] | None = None, - active: bool = True, -) -> Callable[[HandlerCallable], HandlerCallable]: - def decorator(func: HandlerCallable) -> HandlerCallable: - tool_name = str(name or func.__name__).strip() - if not tool_name: - raise ValueError("LLM tool name must not be empty") - setattr( - func, - LLM_TOOL_META_ATTR, - LLMToolMeta( - spec=LLMToolSpec.create( - name=tool_name, - description=description - or (inspect.getdoc(func) or "").splitlines()[0] - if inspect.getdoc(func) - else "", - parameters_schema=parameters_schema - or _callable_parameters_schema(func), - handler_ref=tool_name, - active=active, - ) - ), - ) - return func - - return decorator - - -def register_agent( - name: str, - *, - description: str = "", - tool_names: list[str] | None = None, -) -> Callable[[type[BaseAgentRunner]], type[BaseAgentRunner]]: - def decorator(cls: type[BaseAgentRunner]) -> type[BaseAgentRunner]: - if not inspect.isclass(cls) or not issubclass(cls, BaseAgentRunner): - raise TypeError("@register_agent() 只接受 BaseAgentRunner 子类") - setattr( - cls, - AGENT_META_ATTR, - AgentMeta( - spec=AgentSpec( - name=name, - description=description, - tool_names=list(tool_names or []), - runner_class=f"{cls.__module__}.{cls.__qualname__}", - ) - ), - ) - return cls - - return decorator - - -def acknowledge_global_mcp_risk(cls: type[Any]) -> type[Any]: - """Mark an SDK plugin class as eligible to mutate global MCP state. - - This is intentionally a coarse, class-level marker. Runtime enforcement lives - in the Core MCP capability bridge. - """ - - setattr(cls, "__astrbot_acknowledge_global_mcp_risk__", True) - return cls diff --git a/astrbot-sdk/src/astrbot_sdk/docs/01_context_api.md b/astrbot-sdk/src/astrbot_sdk/docs/01_context_api.md deleted file mode 100644 index 95a425262..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/01_context_api.md +++ /dev/null @@ -1,665 +0,0 @@ -# AstrBot SDK Context API 参考文档 - -## 概述 - -`Context` 是插件与 AstrBot Core 交互的主要入口,每个 handler 调用都会创建一个新的 Context 实例。Context 组合了所有 capability 客户端,提供统一的访问接口。 - -## 目录 - -- [Context 类属性](#context-类属性) -- [核心客户端](#核心客户端) -- [LLM 客户端 (ctx.llm)](#llm-客户端) -- [Memory 客户端 (ctx.memory)](#memory-客户端) -- [Database 客户端 (ctx.db)](#database-客户端) -- [Files 客户端 (ctx.files)](#files-客户端) -- [Platform 客户端 (ctx.platform)](#platform-客户端) -- [Provider 客户端 (ctx.providers)](#provider-客户端) -- [HTTP 客户端 (ctx.http)](#http-客户端) -- [Metadata 客户端 (ctx.metadata)](#metadata-客户端) -- [LLM Tool 管理方法](#llm-tool-管理方法) -- [系统工具方法](#系统工具方法) - ---- - -## Context 类属性 - -### 基本属性 - -```python -@dataclass -class Context: - peer: Any # 协议对等端,用于底层通信 - plugin_id: str # 当前插件 ID - logger: PluginLogger # 绑定了插件 ID 的日志器 - cancel_token: CancelToken # 取消令牌,用于处理请求取消 -``` - -### 客户端属性 - -```python -ctx.llm: LLMClient # LLM 能力客户端 -ctx.memory: MemoryClient # 记忆能力客户端 -ctx.db: DBClient # 数据库客户端 -ctx.files: FileServiceClient # 文件服务客户端 -ctx.platform: PlatformClient # 平台客户端 -ctx.providers: ProviderClient # Provider 客户端 -ctx.provider_manager: ProviderManagerClient # Provider 管理客户端 -ctx.personas: PersonaManagerClient # 人格管理客户端 -ctx.conversations: ConversationManagerClient # 对话管理客户端 -ctx.kbs: KnowledgeBaseManagerClient # 知识库管理客户端 -ctx.http: HTTPClient # HTTP 客户端 -ctx.metadata: MetadataClient # 元数据客户端 -``` - ---- - -## 核心客户端 - -### logger - -绑定了插件 ID 的日志器,自动添加插件上下文信息。 - -```python -# 不同级别的日志 -ctx.logger.debug("调试信息") -ctx.logger.info("普通信息") -ctx.logger.warning("警告信息") -ctx.logger.error("错误信息") - -# 绑定额外上下文 -logger = ctx.logger.bind(user_id="12345") -logger.info("用户操作") - -# 流式日志监听 -async for entry in ctx.logger.watch(): - print(f"[{entry.level}] {entry.message}") -``` - -### cancel_token - -取消令牌,用于长时间运行的任务中检查是否需要取消。 - -```python -# 检查是否取消 -ctx.cancel_token.raise_if_cancelled() - -# 触发取消 -ctx.cancel_token.cancel() - -# 等待取消信号 -await ctx.cancel_token.wait() -``` - ---- - -## LLM 客户端 - -### chat() - -发送聊天请求并返回文本响应。 - -```python -async def chat( - prompt: str, - *, - system: str | None = None, - history: Sequence[ChatHistoryItem] | None = None, - provider_id: str | None = None, - model: str | None = None, - temperature: float | None = None, - **kwargs: Any, -) -> str -``` - -**使用示例:** - -```python -# 简单对话 -reply = await ctx.llm.chat("你好,介绍一下自己") - -# 带系统提示词 -reply = await ctx.llm.chat( - "用 Python 写一个快速排序", - system="你是一个专业的程序员助手" -) - -# 带历史的对话 -from astrbot_sdk.clients.llm import ChatMessage - -history = [ - ChatMessage(role="user", content="我叫小明"), - ChatMessage(role="assistant", content="你好小明!"), -] -reply = await ctx.llm.chat("你记得我的名字吗?", history=history) -``` - -### chat_raw() - -发送聊天请求并返回完整响应对象。 - -```python -response = await ctx.llm.chat_raw("写一首诗", temperature=0.8) -print(f"生成文本: {response.text}") -print(f"Token 使用: {response.usage}") -print(f"结束原因: {response.finish_reason}") -``` - -### stream_chat() - -流式聊天,逐块返回响应文本。 - -```python -async for chunk in ctx.llm.stream_chat("讲一个故事"): - print(chunk, end="", flush=True) -``` - ---- - -## Memory 客户端 - -### search() - -搜索记忆项。默认在有 embedding provider 时执行 hybrid 检索。 - -```python -results = await ctx.memory.search("用户喜欢什么颜色", mode="hybrid", limit=5) -for item in results: - print(item["key"], item["score"], item["match_type"]) -``` - -### save() - -保存记忆项。 - -```python -# 保存用户偏好 -await ctx.memory.save("user_pref", {"theme": "dark", "lang": "zh"}) - -# 使用关键字参数 -await ctx.memory.save("note", None, content="重要笔记", tags=["work"]) - -# 显式指定检索文本 -await ctx.memory.save( - "profile:alice", - {"name": "Alice", "embedding_text": "Alice 喜欢蓝色和海边"}, -) -``` - -### get() - -精确获取单个记忆项。 - -```python -pref = await ctx.memory.get("user_pref") -if pref: - print(f"用户偏好主题: {pref.get('theme')}") -``` - -### save_with_ttl() - -保存带过期时间的记忆项。 - -```python -# 保存临时会话状态,1小时后过期 -await ctx.memory.save_with_ttl( - "session_temp", - {"state": "waiting"}, - ttl_seconds=3600 -) -``` - -### stats() - -查看记忆索引状态。 - -```python -stats = await ctx.memory.stats() -print(stats["total_items"], stats.get("embedded_items"), stats.get("dirty_items")) -``` - ---- - -## Database 客户端 - -### get() - -获取指定键的值。 - -```python -data = await ctx.db.get("user_settings") -if data: - print(data["theme"]) -``` - -### set() - -设置键值对。 - -```python -await ctx.db.set("user_settings", {"theme": "dark", "lang": "zh"}) -await ctx.db.set("greeted", True) -``` - -### delete() - -删除指定键的数据。 - -```python -await ctx.db.delete("user_settings") -``` - -### list() - -列出匹配前缀的所有键。 - -```python -keys = await ctx.db.list("user_") -# ["user_settings", "user_profile", "user_history"] -``` - -### get_many() - -批量获取多个键的值。 - -```python -values = await ctx.db.get_many(["user:1", "user:2"]) -``` - -### set_many() - -批量写入多个键值对。 - -```python -await ctx.db.set_many({ - "user:1": {"name": "Alice"}, - "user:2": {"name": "Bob"} -}) -``` - -### watch() - -订阅 KV 变更事件(流式)。 - -```python -async for event in ctx.db.watch("user:"): - print(event["op"], event["key"]) -``` - ---- - -## Files 客户端 - -### register_file() - -注册文件并获取令牌。 - -```python -token = await ctx.files.register_file("/path/to/file.jpg", timeout=3600) -``` - -### handle_file() - -通过令牌解析文件路径。 - -```python -path = await ctx.files.handle_file(token) -``` - ---- - -## Platform 客户端 - -### send() - -发送文本消息。 - -```python -await ctx.platform.send(event.session_id, "收到您的消息!") -``` - -### send_image() - -发送图片消息。 - -```python -await ctx.platform.send_image( - event.session_id, - "https://example.com/image.png" -) -``` - -### send_chain() - -发送富消息链。 - -```python -from astrbot_sdk.message_components import Plain, Image - -chain = [Plain("文字"), Image(url="https://example.com/img.jpg")] -await ctx.platform.send_chain(event.session_id, chain) -``` - -### send_by_id() - -主动向指定平台会话发送消息。 - -```python -await ctx.platform.send_by_id( - platform_id="qq", - session_id="user123", - content="Hello", - message_type="private" -) -``` - -### get_members() - -获取群组成员列表。 - -```python -members = await ctx.platform.get_members("qq:group:123456") -for member in members: - print(f"{member['nickname']} ({member['user_id']})") -``` - ---- - -## Provider 客户端 - -### list_all() - -列出所有 Provider。 - -```python -providers = await ctx.providers.list_all() -for p in providers: - print(f"{p.id}: {p.model}") -``` - -### get_using_chat() - -获取当前使用的聊天 Provider。 - -```python -provider = await ctx.providers.get_using_chat() -if provider: - print(f"当前使用: {provider.id}") -``` - ---- - -## HTTP 客户端 - -### register_api() - -注册 Web API 端点。 - -```python -from astrbot_sdk.decorators import provide_capability - -@provide_capability( - name="my_plugin.http_handler", - description="处理 HTTP 请求" -) -async def handle_http_request(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - -await ctx.http.register_api( - route="/my-api", - handler=handle_http_request, - methods=["GET", "POST"] -) -``` - -### unregister_api() - -注销 Web API 端点。 - -```python -await ctx.http.unregister_api("/my-api") -``` - -### list_apis() - -列出当前插件注册的所有 API。 - -```python -apis = await ctx.http.list_apis() -for api in apis: - print(f"{api['route']}: {api['methods']}") -``` - ---- - -## Metadata 客户端 - -### get_plugin() - -获取指定插件信息。 - -```python -plugin = await ctx.metadata.get_plugin("another_plugin") -if plugin: - print(f"插件: {plugin.display_name}") - print(f"版本: {plugin.version}") -``` - -### list_plugins() - -获取所有插件列表。 - -```python -plugins = await ctx.metadata.list_plugins() -for plugin in plugins: - print(f"{plugin.display_name} v{plugin.version}") -``` - -### get_current_plugin() - -获取当前插件信息。 - -```python -current = await ctx.metadata.get_current_plugin() -if current: - print(f"当前插件: {current.name} v{current.version}") -``` - -### get_plugin_config() - -获取插件配置。 - -```python -config = await ctx.metadata.get_plugin_config() -if config: - api_key = config.get("api_key") -``` - ---- - -## LLM Tool 管理方法 - -### register_llm_tool() - -注册可执行的 LLM 工具。 - -```python -async def search_weather(location: str) -> str: - return f"{location} 今天晴天" - -await ctx.register_llm_tool( - name="search_weather", - parameters_schema={ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名称"} - }, - "required": ["location"] - }, - desc="搜索天气信息", - func_obj=search_weather, - active=True -) -``` - -### add_llm_tools() - -添加 LLM 工具规范。 - -```python -from astrbot_sdk.llm.tools import LLMToolSpec - -tool_spec = LLMToolSpec( - name="my_tool", - description="我的工具", - parameters_schema={...} -) - -await ctx.add_llm_tools(tool_spec) -``` - -### activate_llm_tool() / deactivate_llm_tool() - -激活/停用 LLM 工具。 - -```python -await ctx.activate_llm_tool("my_tool") -await ctx.deactivate_llm_tool("my_tool") -``` - ---- - -## 系统工具方法 - -### get_data_dir() - -获取插件数据目录路径。 - -```python -data_dir = await ctx.get_data_dir() -print(f"数据目录: {data_dir}") -``` - -### text_to_image() - -将文本渲染为图片。 - -```python -url = await ctx.text_to_image("Hello World", return_url=True) -``` - -### html_render() - -渲染 HTML 模板。 - -```python -url = await ctx.html_render( - tmpl="

{{ title }}

", - data={"title": "标题"} -) -``` - -### send_message() - -向会话发送消息。 - -```python -await ctx.send_message(event.session_id, "消息内容") -``` - -### send_message_by_id() - -通过 ID 向平台发送消息。 - -```python -await ctx.send_message_by_id( - type="private", - id="user123", - content="Hello", - platform="qq" -) -``` - -### register_task() - -注册后台任务。 - -```python -async def background_work(): - while True: - await asyncio.sleep(60) - ctx.logger.info("每分钟执行一次") - -task = await ctx.register_task(background_work(), "定时任务") -``` - ---- - -## 常见使用模式 - -### 1. 基本对话流程 - -```python -from astrbot_sdk.decorators import on_message - -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - reply = await ctx.llm.chat(event.message_content) - await ctx.platform.send(event.session_id, reply) -``` - -### 2. 带历史的对话 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 从 memory 获取历史 - history_data = await ctx.memory.get(f"history:{event.session_id}") - history = history_data.get("messages", []) if history_data else [] - - # 对话 - reply = await ctx.llm.chat(event.message_content, history=history) - - # 保存新消息到历史 - history.append(ChatMessage(role="user", content=event.message_content)) - history.append(ChatMessage(role="assistant", content=reply)) - await ctx.memory.save(f"history:{event.session_id}", {"messages": history}) - - await ctx.platform.send(event.session_id, reply) -``` - -### 3. 使用数据库持久化 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 获取用户配置 - config = await ctx.db.get(f"user_config:{event.sender_id}") - - if not config: - config = {"theme": "light", "lang": "zh"} - await ctx.db.set(f"user_config:{event.sender_id}", config) - - # 使用配置 - reply = f"你的主题设置是: {config['theme']}" - await ctx.platform.send(event.session_id, reply) -``` - ---- - -## 注意事项 - -1. **跨进程通信**:Context 通过 capability 协议与核心通信,所有方法调用都是异步的 - -2. **插件隔离**:每个插件有独立的 Context 实例,数据和配置是隔离的 - -3. **取消处理**:长时间运行的操作应定期检查 `ctx.cancel_token.raise_if_cancelled()` - -4. **错误处理**:所有远程调用都可能失败,建议使用 try-except 处理 - -5. **Memory vs DB**: - - Memory: 语义搜索,适合 AI 上下文 - - DB: 精确匹配,适合结构化数据 - -6. **文件操作**:使用 `ctx.files` 注册文件令牌,不要直接传递本地路径 - -7. **平台标识**:使用 UMO(统一消息来源标识)格式:`"platform:instance:session_id"` diff --git a/astrbot-sdk/src/astrbot_sdk/docs/02_event_and_components.md b/astrbot-sdk/src/astrbot_sdk/docs/02_event_and_components.md deleted file mode 100644 index af663e0ac..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/02_event_and_components.md +++ /dev/null @@ -1,593 +0,0 @@ -# AstrBot SDK 消息事件与组件 API 参考文档 - -## 概述 - -本文档详细介绍 `astrbot_sdk` 中消息事件和消息组件的使用方法,包括 `MessageEvent` 类和所有消息组件类。 - -## 目录 - -- [MessageEvent - 消息事件对象](#messageevent---消息事件对象) -- [消息组件类](#消息组件类) -- [MessageChain - 消息链](#messagechain---消息链) -- [MessageBuilder - 消息构建器](#messagebuilder---消息构建器) - ---- - -## MessageEvent - 消息事件对象 - -**模块路径**: `astrbot_sdk.events.MessageEvent` - -### 核心属性 - -| 属性名 | 类型 | 说明 | -|--------|------|------| -| `text` | `str` | 消息文本内容 | -| `user_id` | `str \| None` | 发送者用户 ID | -| `group_id` | `str \| None` | 群组 ID(私聊时为 None) | -| `platform` | `str \| None` | 平台标识(如 "qq", "wechat") | -| `session_id` | `str` | 会话 ID | -| `self_id` | `str` | 机器人账号 ID | -| `platform_id` | `str` | 平台实例标识 | -| `message_type` | `str` | 消息类型("private" 或 "group") | -| `sender_name` | `str` | 发送者昵称 | - -### 消息组件访问方法 - -#### `get_messages()` - -获取当前事件的所有 SDK 消息组件。 - -```python -components = event.get_messages() -for comp in components: - print(f"组件类型: {comp.type}") -``` - -#### `has_component(type_)` - -检查是否包含特定类型的组件。 - -```python -if event.has_component(Image): - print("消息包含图片") -``` - -#### `get_components(type_)` - -获取特定类型的所有组件。 - -```python -at_comps = event.get_components(At) -for at in at_comps: - print(f"@了用户: {at.qq}") -``` - -#### `get_images()` - -获取所有图片组件。 - -```python -images = event.get_images() -for img in images: - path = await img.convert_to_file_path() - print(f"图片路径: {path}") -``` - -#### `get_files()` - -获取所有文件组件。 - -```python -files = event.get_files() -``` - -#### `extract_plain_text()` - -提取所有纯文本内容。 - -```python -text = event.extract_plain_text() -``` - -#### `get_at_users()` - -获取消息中所有被@的用户ID列表。 - -```python -at_users = event.get_at_users() -``` - -### 会话与平台信息方法 - -#### `is_private_chat()` / `is_group_chat()` - -判断消息类型。 - -```python -if event.is_private_chat(): - await event.reply("这是私聊") -elif event.is_group_chat(): - await event.reply("这是群聊") -``` - -#### `is_admin()` - -判断发送者是否有管理员权限。 - -```python -if event.is_admin(): - await event.reply("你是管理员") -``` - -### 回复与发送方法 - -#### `reply(text)` - -回复纯文本消息。 - -```python -await event.reply("Hello World!") -``` - -#### `reply_image(image_url)` - -回复图片消息。 - -```python -await event.reply_image("https://example.com/image.jpg") -``` - -#### `reply_chain(chain)` - -回复消息链。 - -```python -from astrbot_sdk.message_components import Plain, At - -await event.reply_chain([ - Plain("Hello "), - At("123456"), - Plain("!") -]) -``` - -### 事件控制方法 - -#### `stop_event()` - -标记事件为已停止,阻止后续处理器执行。 - -```python -event.stop_event() -``` - -### 结果构建方法 - -#### `plain_result(text)` - -创建纯文本结果。 - -```python -return event.plain_result("回复内容") -``` - -#### `image_result(url_or_path)` - -创建图片结果。 - -```python -return event.image_result("https://example.com/image.jpg") -``` - -#### `chain_result(chain)` - -创建链结果。 - -```python -return event.chain_result([ - Plain("Hello"), - At("123456") -]) -``` - ---- - -## 消息组件类 - -### Plain - 纯文本组件 - -```python -from astrbot_sdk.message_components import Plain - -text = Plain("Hello World") -``` - -### At - @某人组件 - -```python -from astrbot_sdk.message_components import At - -at = At("123456", name="张三") -``` - -### AtAll - @全体成员组件 - -```python -from astrbot_sdk.message_components import AtAll - -at_all = AtAll() -``` - -### Image - 图片组件 - -```python -from astrbot_sdk.message_components import Image - -# URL 图片 -img1 = Image.fromURL("https://example.com/image.jpg") - -# 本地文件 -img2 = Image.fromFileSystem("/path/to/image.jpg") - -# Base64 -img3 = Image.fromBase64("iVBORw0KGgo...") -``` - -### Record - 语音组件 - -```python -from astrbot_sdk.message_components import Record - -# URL 音频 -audio = Record.fromURL("https://example.com/audio.mp3") - -# 本地文件 -audio = Record.fromFileSystem("/path/to/audio.mp3") -``` - -### Video - 视频组件 - -```python -from astrbot_sdk.message_components import Video - -video = Video.fromURL("https://example.com/video.mp4") -``` - -### File - 文件组件 - -```python -from astrbot_sdk.message_components import File - -# URL 文件 -file1 = File(name="document.pdf", url="https://example.com/doc.pdf") - -# 本地文件 -file2 = File(name="image.jpg", file="/path/to/image.jpg") -``` - -### Reply - 回复组件 - -```python -from astrbot_sdk.message_components import Reply, Plain - -reply = Reply( - id="msg_123", - sender_id="789", - chain=[Plain("被回复的消息")] -) -``` - ---- - -## MessageChain - 消息链 - -### 构造方法 - -```python -from astrbot_sdk.message_result import MessageChain -from astrbot_sdk.message_components import Plain, At - -# 空消息链 -chain = MessageChain() - -# 带初始组件 -chain = MessageChain([Plain("Hello"), At("123456")]) -``` - -### 实例方法 - -#### `append(component)` - -追加单个组件。 - -```python -chain.append(Plain("More text")) -``` - -#### `extend(components)` - -追加多个组件。 - -```python -chain.extend([Plain("A"), Plain("B")]) -``` - -#### `to_payload()` - -转换为协议 payload。 - -```python -payload = chain.to_payload() -``` - -#### `get_plain_text()` - -提取纯文本内容。 - -```python -text = chain.get_plain_text() -``` - ---- - -## MessageBuilder - 消息构建器 - -### 使用示例 - -```python -from astrbot_sdk.message_result import MessageBuilder - -chain = (MessageBuilder() - .text("Hello ") - .at("123456") - .text("!\n") - .image("https://example.com/img.jpg") - .build()) - -await event.reply_chain(chain) -``` - -### 可用方法 - -- `.text(content)` - 添加文本 -- `.at(user_id)` - 添加@用户 -- `.at_all()` - 添加@全体成员 -- `.image(url)` - 添加图片 -- `.record(url)` - 添加语音 -- `.video(url)` - 添加视频 -- `.file(name, url=...)` - 添加文件 -- `.build()` - 构建消息链 - ---- - -## 使用示例 - -### 处理图片消息 - -```python -@on_message() -async def handle_image(event: MessageEvent): - images = event.get_images() - if not images: - await event.reply("消息中没有图片") - return - - for img in images: - path = await img.convert_to_file_path() - await event.reply(f"收到图片: {path}") -``` - -### 检测@和群聊/私聊 - -```python -@on_command("check") -async def check_handler(event: MessageEvent): - if event.is_group_chat(): - await event.reply("这是群聊消息") - elif event.is_private_chat(): - await event.reply("这是私聊消息") - - at_users = event.get_at_users() - if at_users: - await event.reply(f"你@了: {', '.join(at_users)}") -``` - -### 返回富文本结果 - -```python -@on_command("info") -async def info_handler(event: MessageEvent): - return event.chain_result([ - Plain(f"用户: {event.sender_name}\n"), - Plain(f"ID: {event.user_id}\n"), - Plain(f"平台: {event.platform}"), - ]) - ``` - - --- - - ## 媒体辅助类 - - ### MediaHelper - - 媒体辅助类,提供从 URL 检测媒体类型和下载功能。 - - ```python - from astrbot_sdk.message_components import MediaHelper - ``` - - #### from_url - 从 URL 创建组件 - - 自动检测 URL 的媒体类型并创建对应的消息组件。 - - ```python - from astrbot_sdk.message_components import MediaHelper - - # 自动检测媒体类型 - component = await MediaHelper.from_url("https://example.com/video.mp4") - # 返回 Video 组件 - - component = await MediaHelper.from_url("https://example.com/image.jpg") - # 返回 Image 组件 - - component = await MediaHelper.from_url("https://example.com/audio.mp3") - # 返回 Record 组件 - ``` - - **参数**: - - `url`: 媒体文件 URL - - `headers`: 可选的请求头 - - **返回值**: - - `Image` / `Video` / `Record` / `File` 组件实例 - - #### download - 下载媒体文件 - - 下载媒体文件到本地。 - - ```python - from astrbot_sdk.message_components import MediaHelper - from pathlib import Path - - # 下载到指定目录 - path = await MediaHelper.download( - url="https://example.com/video.mp4", - save_dir=Path("/tmp/downloads") - ) - print(f"下载到: {path}") # /tmp/downloads/video.mp4 - - # 下载到当前目录 - path = await MediaHelper.download( - url="https://example.com/image.png" - ) - ``` - - **参数**: - - `url`: 文件 URL - - `save_dir`: 保存目录(可选,默认为当前目录) - - `filename`: 指定文件名(可选,自动从 URL 或响应头推断) - - `headers`: 请求头(可选) - - **返回值**: - - `Path`: 下载文件的本地路径 - - **示例:完整媒体处理流程** - - ```python - from astrbot_sdk import Star, Context, MessageEvent - from astrbot_sdk.decorators import on_command - from astrbot_sdk.message_components import MediaHelper, Plain - - class MediaPlugin(Star): - @on_command("download") - async def download_media(self, event: MessageEvent, ctx: Context, url: str): - """下载媒体文件""" - try: - # 发送下载中提示 - await event.reply(f"正在下载: {url}") - - # 下载文件 - path = await MediaHelper.download(url) - - # 创建对应组件并发送 - component = await MediaHelper.from_url(url) - component.file = str(path) # 使用本地文件 - - await event.reply([Plain("下载完成!"), component]) - except Exception as e: - await event.reply(f"下载失败: {e}") - - @on_command("mirror") - async def mirror_media(self, event: MessageEvent, ctx: Context): - """转发收到的媒体""" - images = event.get_images() - if images: - for img in images: - # 下载并重新发送 - if img.url: - local_path = await MediaHelper.download(img.url) - await event.reply(f"已镜像保存: {local_path}") - ``` - - --- - - ## 未知组件 - - ### UnknownComponent - - 未知消息组件,用于表示 SDK 无法识别的平台特定组件。 - - ```python - from astrbot_sdk.message_components import UnknownComponent - ``` - - **说明**: - - 当收到 SDK 不支持的消息类型时,会返回此组件 - - 保留原始数据供插件自行处理 - - 通常出现在新平台或平台新功能中 - - **属性**: - - `raw_data`: 原始组件数据(dict) - - `type`: 组件类型字符串 - - ```python - @on_message() - async def handle_unknown(self, event: MessageEvent, ctx: Context): - components = event.get_messages() - for comp in components: - if isinstance(comp, UnknownComponent): - ctx.logger.warning(f"未知组件类型: {comp.type}") - ctx.logger.debug(f"原始数据: {comp.raw_data}") - # 插件可以尝试自行处理 raw_data - ``` - - --- - - ## 特殊消息组件 - - ### Forward - 合并转发消息 - - 合并转发消息组件(仅部分平台支持,如 QQ)。 - - ```python - from astrbot_sdk.message_components import Forward, ForwardNode - - # 创建转发消息(需要平台支持) - nodes = [ - ForwardNode( - user_id="123456", - nickname="用户A", - content=[Plain("消息内容1")] - ), - ForwardNode( - user_id="789012", - nickname="用户B", - content=[Plain("消息内容2")] - ), - ] - forward = Forward(nodes=nodes) - ``` - - **注意**:Forward 组件的支持程度取决于具体平台适配器。 - - ### Poke - 戳一戳/拍一拍 - - 戳一戳消息组件(QQ 等平台支持)。 - - ```python - from astrbot_sdk.message_components import Poke - - # 发送戳一戳 - poke = Poke(user_id="123456") - await event.reply(poke) - - # 检测戳一戳 - @on_message() - async def on_poke(self, event: MessageEvent, ctx: Context): - for comp in event.get_messages(): - if isinstance(comp, Poke): - await event.reply(f"{event.sender_name} 戳了你一下!") - ``` - - **属性**: - - `user_id`: 被戳的用户 ID diff --git a/astrbot-sdk/src/astrbot_sdk/docs/03_decorators.md b/astrbot-sdk/src/astrbot_sdk/docs/03_decorators.md deleted file mode 100644 index 6a106f98e..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/03_decorators.md +++ /dev/null @@ -1,610 +0,0 @@ -# AstrBot SDK 装饰器使用指南 - -## 概述 - -本文档详细介绍 `astrbot_sdk.decorators` 中所有装饰器的使用方法、参数说明和最佳实践。 - -## 目录 - -- [事件触发装饰器](#事件触发装饰器) -- [修饰器装饰器](#修饰器装饰器) -- [过滤器装饰器](#过滤器装饰器) -- [限制器装饰器](#限制器装饰器) -- [能力暴露装饰器](#能力暴露装饰器) -- [LLM 工具装饰器](#llm-工具装饰器) -- [最佳实践](#最佳实践) - ---- - -## 事件触发装饰器 - -### @on_command - -命令触发装饰器。 - -**签名:** -```python -def on_command( - command: str | Sequence[str], - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> Callable -``` - -**参数:** -- `command`: 命令名称(不包含前缀符) -- `aliases`: 命令别名列表 -- `description`: 命令描述 - -**示例:** - -```python -from astrbot_sdk.decorators import on_command - -@on_command("hello") -async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") - -@on_command(["echo", "repeat"], aliases=["say", "speak"]) -async def echo(self, event: MessageEvent, text: str): - await event.reply(text) -``` - -### @on_message - -消息触发装饰器。 - -**签名:** -```python -def on_message( - *, - regex: str | None = None, - keywords: list[str] | None = None, - platforms: list[str] | None = None, - message_types: list[str] | None = None, -) -> Callable -``` - -**参数:** -- `regex`: 正则表达式模式 -- `keywords`: 关键词列表(任一匹配即触发) -- `platforms`: 限定平台列表 -- `message_types`: 限定消息类型("group", "private") - -**示例:** - -```python -# 关键词匹配 -@on_message(keywords=["帮助", "help"]) -async def help_handler(self, event: MessageEvent, ctx: Context): - await event.reply("可用命令: /hello") - -# 正则匹配 -@on_message(regex=r"\d{4,}") -async def number_handler(self, event: MessageEvent, ctx: Context): - await event.reply("检测到数字!") - -# 多条件过滤 -@on_message( - keywords=["天气"], - platforms=["qq"], - message_types=["private"] -) -async def weather_query(self, event: MessageEvent, ctx: Context): - await event.reply("请输入城市名称") -``` - -### @on_event - -事件触发装饰器。 - -**签名:** -```python -def on_event(event_type: str) -> Callable -``` - -**示例:** - -```python -@on_event("group_member_join") -async def welcome_new_member(self, event, ctx: Context): - await ctx.platform.send(event.group_id, "欢迎新成员!") -``` - -### @on_schedule - -定时任务装饰器。 - -**签名:** -```python -def on_schedule( - *, - cron: str | None = None, - interval_seconds: int | None = None, -) -> Callable -``` - -**示例:** - -```python -# 固定间隔 -@on_schedule(interval_seconds=3600) -async def hourly_check(self, ctx: Context): - pass - -# cron 表达式 -@on_schedule(cron="0 8 * * *") # 每天 8:00 -async def morning_greeting(self, ctx: Context): - await ctx.platform.send("group_123", "早上好!") -``` - ---- - -## 修饰器装饰器 - -### @require_admin - -管理员权限装饰器。 - -**示例:** - -```python -from astrbot_sdk.decorators import on_command, require_admin - -@on_command("admin") -@require_admin -async def admin_cmd(self, event: MessageEvent, ctx: Context): - await event.reply("管理员命令") -``` - ---- - -## 过滤器装饰器 - -### @platforms - -限定平台装饰器。 - -**签名:** -```python -def platforms(*names: str) -> Callable -``` - -**示例:** - -```python -@on_command("qq_only") -@platforms("qq") -async def qq_only_command(self, event: MessageEvent, ctx: Context): - await event.reply("这是 QQ 专属命令") -``` - -### @message_types - -限定消息类型装饰器。 - -**签名:** -```python -def message_types(*types: str) -> Callable -``` - -**示例:** - -```python -@on_command("group_only") -@message_types("group") -async def group_command(self, event: MessageEvent, ctx: Context): - await event.reply("这是群聊命令") -``` - -### @group_only - -仅群聊装饰器。 - -```python -@on_command("group_admin") -@group_only() -async def group_admin_command(self, event: MessageEvent, ctx: Context): - await event.reply("这是群聊管理命令") -``` - -### @private_only - -仅私聊装饰器。 - -```python -@on_command("private_chat") -@private_only() -async def private_command(self, event: MessageEvent, ctx: Context): - await event.reply("这是私聊命令") -``` - ---- - -## 限制器装饰器 - -### @rate_limit - -速率限制装饰器。 - -**签名:** -```python -def rate_limit( - limit: int, - window: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable -``` - -**参数:** -- `limit`: 时间窗口内最大调用次数 -- `window`: 时间窗口大小(秒) -- `scope`: 限制范围("session", "user", "group", "global") -- `behavior`: 触发限制后的行为("hint", "silent", "error") - -**示例:** - -```python -@on_command("search") -@rate_limit(5, 60) # 每分钟最多5次 -async def search_command(self, event: MessageEvent, ctx: Context): - await event.reply("搜索结果...") - -@on_command("draw") -@rate_limit(3, 3600, scope="user") # 每用户每小时3次 -async def draw_command(self, event: MessageEvent, ctx: Context): - await event.reply("绘图结果...") -``` - -### @cooldown - -冷却时间装饰器。 - -**签名:** -```python -def cooldown( - seconds: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable -``` - -**示例:** - -```python -@on_command("cast_skill") -@cooldown(30) # 30秒冷却 -async def cast_skill_command(self, event: MessageEvent, ctx: Context): - await event.reply("技能施放成功!") -``` - ---- - -### @admin_only - -管理员权限装饰器(`@require_admin` 的别名)。 - -**签名:** -```python -def admin_only(func: HandlerCallable) -> HandlerCallable -``` - -**示例:** - -```python -from astrbot_sdk.decorators import on_command, admin_only - -@on_command("admin") -@admin_only -async def admin_cmd(self, event: MessageEvent, ctx: Context): - await event.reply("管理员命令") -``` - -**说明:** -- 功能与 `@require_admin` 完全相同 -- 更简洁的语法,无需括号 -- 适合快速标记管理员命令 - ---- - -## 优先级装饰器 - -### @priority - -设置 handler 执行优先级。 - -**签名:** -```python -def priority(value: int) -> Callable[[HandlerCallable], HandlerCallable] -``` - -**参数:** -- `value`: 优先级数值,**越大越先执行** -- 默认优先级为 0 - -**示例:** - -```python -from astrbot_sdk.decorators import on_command, priority - -@on_command("hello") -@priority(10) # 高优先级,先执行 -async def hello_high(self, event: MessageEvent, ctx: Context): - await event.reply("高优先级处理器") - -@on_command("hello") -@priority(5) # 较低优先级,后执行 -async def hello_low(self, event: MessageEvent, ctx: Context): - await event.reply("低优先级处理器") -``` - -**使用场景:** -- 多个插件注册了相同命令时控制执行顺序 -- 确保核心处理器先于扩展处理器执行 -- 实现插件间的协作处理链 - -**注意事项:** -- 相同优先级的 handler 执行顺序不确定 -- 高优先级 handler 不会阻止低优先级 handler 执行(除非显式阻止) - ---- - -## 对话装饰器 - -### @conversation_command - -对话命令装饰器,用于创建交互式对话流程。 - -**签名:** -```python -def conversation_command( - command: str, - *, - timeout: float = 300.0, - description: str | None = None, -) -> Callable -``` - -**参数:** -- `command`: 命令名称 -- `timeout`: 对话超时时间(秒),默认 300 -- `description`: 命令描述 - -**示例:** - -```python -from astrbot_sdk.decorators import conversation_command -from astrbot_sdk.conversation import ConversationSession - -@conversation_command("survey", timeout=600) -async def survey(self, event: MessageEvent, ctx: Context, session: ConversationSession): - """交互式调查问卷""" - # 第一轮对话 - await event.reply("请输入您的姓名:") - - # 等待用户回复(在下一个处理器中处理) - session.state["step"] = "name" - -@conversation_command("survey") -async def survey_step2(self, event: MessageEvent, ctx: Context, session: ConversationSession): - """问卷第二步""" - step = session.state.get("step") - - if step == "name": - session.state["name"] = event.text - session.state["step"] = "age" - await event.reply("请输入您的年龄:") - elif step == "age": - session.state["age"] = event.text - # 完成问卷 - await event.reply(f"感谢您的参与!姓名:{session.state['name']}, 年龄:{event.text}") - session.close() # 关闭对话会话 -``` - -**工作流程:** -1. 用户发送 `/survey` 触发第一个处理器 -2. 处理器使用 `ConversationSession` 维护对话状态 -3. 后续消息在同一会话中路由到相同命令的处理器 -4. 超时或调用 `session.close()` 结束对话 - -**异常处理:** - -```python -from astrbot_sdk.conversation import ConversationClosed, ConversationReplaced - -@conversation_command("demo") -async def demo(self, event: MessageEvent, ctx: Context, session: ConversationSession): - try: - await event.reply("输入 'exit' 结束对话") - if event.text.lower() == "exit": - session.close() - except ConversationClosed: - # 会话被关闭 - await event.reply("对话已结束") - except ConversationReplaced: - # 会话被新会话替换 - await event.reply("开始新的对话") -``` - ---- - -## 能力暴露装饰器 - -### @provide_capability - -暴露能力装饰器。 - -**签名:** -```python -def provide_capability( - name: str, - *, - description: str, - input_schema: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, - input_model: type[BaseModel] | None = None, - output_model: type[BaseModel] | None = None, - supports_stream: bool = False, - cancelable: bool = False, -) -> Callable -``` - -**示例:** - -```python -from pydantic import BaseModel, Field -from astrbot_sdk.decorators import provide_capability - -class CalculateInput(BaseModel): - x: int = Field(description="第一个数") - y: int = Field(description="第二个数") - -@provide_capability( - "my_plugin.calculate", - description="执行加法计算", - input_model=CalculateInput -) -async def calculate(self, payload: dict, ctx: Context): - x = payload["x"] - y = payload["y"] - return {"result": x + y} -``` - ---- - -## LLM 工具装饰器 - -### @register_llm_tool - -注册 LLM 工具装饰器。 - -**签名:** -```python -def register_llm_tool( - name: str | None = None, - *, - description: str | None = None, - parameters_schema: dict[str, Any] | None = None, - active: bool = True, -) -> Callable -``` - -**示例:** - -```python -from astrbot_sdk.decorators import register_llm_tool - -@register_llm_tool() -async def get_weather(self, city: str, unit: str = "celsius"): - """获取指定城市的天气信息""" - return f"{city} 的天气: 25°C" -``` - -### @register_agent - -注册 Agent 装饰器。 - -**签名:** -```python -def register_agent( - name: str, - *, - description: str = "", - tool_names: list[str] | None = None, -) -> Callable -``` - -**示例:** - -```python -from astrbot_sdk.decorators import register_agent -from astrbot_sdk.llm.agents import BaseAgentRunner - -@register_agent("my_agent", description="我的智能助手") -class MyAgent(BaseAgentRunner): - async def run(self, ctx: Context, request) -> Any: - return "agent result" -``` - ---- - -## 最佳实践 - -### 1. 装饰器顺序 - -正确的装饰器顺序很重要: - -```python -@on_command("command") # 1. 事件触发装饰器 -@platforms("qq") # 2. 过滤器装饰器 -@rate_limit(5, 60) # 3. 限制器装饰器 -@require_admin # 4. 修饰器装饰器 -async def my_handler(self, event: MessageEvent, ctx: Context): - pass -``` - -### 2. 错误处理 - -始终实现错误处理: - -```python -@on_command("risky_command") -async def risky_handler(self, event: MessageEvent, ctx: Context): - try: - result = await some_risky_operation() - await event.reply(f"成功: {result}") - except Exception as e: - ctx.logger.error(f"操作失败: {e}") - await event.reply("操作失败,请稍后重试") -``` - -### 3. 类型注解 - -使用类型注解提高代码可读性: - -```python -from typing import Optional - -@on_command("greet") -async def greet_handler( - self, - event: MessageEvent, - ctx: Context -) -> None: - await event.reply("Hello!") -``` - -### 4. 避免常见陷阱 - -**不要混用冲突的装饰器:** - -```python -# 错误 -@on_message(platforms=["qq"]) -@platforms("wechat") # 冲突! -async def handler(...): pass - -# 正确 -@on_message(platforms=["qq", "wechat"]) -async def handler(...): pass -``` - -**不要在非消息处理器使用限制器:** - -```python -# 错误 -@on_event("ready") -@rate_limit(5, 60) # 不支持! -async def handler(...): pass - -# 正确 -@on_command("cmd") -@rate_limit(5, 60) -async def handler(...): pass -``` diff --git a/astrbot-sdk/src/astrbot_sdk/docs/04_star_lifecycle.md b/astrbot-sdk/src/astrbot_sdk/docs/04_star_lifecycle.md deleted file mode 100644 index 98041db16..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/04_star_lifecycle.md +++ /dev/null @@ -1,528 +0,0 @@ -# AstrBot SDK Star 类与生命周期指南 - -## 概述 - -`Star` 是 AstrBot v4 SDK 的原生插件基类,提供了完整的插件生命周期管理、上下文访问和能力集成。 - -## 目录 - -- [Star 类概述](#star-类概述) -- [生命周期流程](#生命周期流程) -- [生命周期钩子](#生命周期钩子) -- [Context 上下文使用](#context-上下文使用) -- [插件元数据访问](#插件元数据访问) -- [错误处理模式](#错误处理模式) -- [最佳实践](#最佳实践) - ---- - -## Star 类概述 - -### 什么是 Star 类? - -`Star` 是所有 v4 原生插件必须继承的基类,提供插件生命周期管理和能力集成。 - -### 核心特性 - -```python -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command, on_message - -class MyPlugin(Star): - """插件类示例""" - - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") -``` - ---- - -## 生命周期流程 - -### 完整生命周期 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 插件加载阶段 │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. 插件发现 (discover_plugins) │ -│ ├─ 扫描插件目录 │ -│ ├─ 读取 plugin.yaml │ -│ └─ 验证组件类 (main:MyPlugin) │ -│ │ -│ 2. 插件加载 │ -│ ├─ 动态导入插件模块 │ -│ ├─ 实例化 Star 子类 │ -│ ├─ 收集 __handlers__ 元组 │ -│ └─ 注册装饰器元数据 │ -│ │ -│ 3. Worker 启动 (PluginWorkerRuntime.start) │ -│ ├─ 向 Core 注册 handlers/capabilities │ -│ └─ 建立通信对等端 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 插件运行阶段 │ -├─────────────────────────────────────────────────────────────────┤ -│ 4. on_start() 生命周期钩子 │ -│ ├─ 绑定运行时上下文 │ -│ ├─ 调用 on_start(ctx) │ -│ └─ 内部调用 initialize() │ -│ │ -│ 5. Handler 事件循环 │ -│ ├─ 等待事件触发 (命令/消息/事件/定时) │ -│ ├─ HandlerDispatcher.invoke() │ -│ ├─ 创建 Context 和 MessageEvent │ -│ ├─ 执行用户 handler │ -│ └─ 处理返回值/异常 │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 插件卸载阶段 │ -├─────────────────────────────────────────────────────────────────┤ -│ 6. on_stop() 生命周期钩子 │ -│ ├─ 调用 on_stop(ctx) │ -│ ├─ 内部调用 terminate() │ -│ ├─ 清理资源 (数据库连接、文件句柄等) │ -│ └─ 重置运行时上下文 │ -│ │ -│ 7. Worker 关闭 │ -│ ├─ 发送 finalize 消息给 Core │ -│ ├─ 关闭通信传输层 │ -│ └─ 退出子进程 │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 生命周期钩子 - -### 1. on_start() - 插件启动钩子 - -**触发时机**:Worker 启动后,在开始处理事件之前调用 - -**参数:** -- `ctx: Any | None` - 运行时上下文(通常为 Context 实例) - -**用途:** -- 初始化数据库连接 -- 加载配置文件 -- 注册 LLM 工具 -- 启动后台任务 - -**最佳实践:** -- `on_start()` 里只做初始化、能力注册和轻量状态恢复 -- 需要长期保存的应是配置值、句柄、任务引用,不要把 `ctx` 实例长期挂到 `self` -- 如果要和 AstrBot 原生 persona / conversation 协作,优先在这里校验或创建所需资源 - -**示例:** - -```python -class MyPlugin(Star): - async def on_start(self, ctx: Any | None = None) -> None: - """插件启动时调用""" - await super().on_start(ctx) - - # 加载配置 - config = await ctx.metadata.get_plugin_config() - self.api_key = config.get("api_key", "") - - # 注册 LLM 工具 - await ctx.register_llm_tool( - name="search", - parameters_schema={...}, - desc="搜索信息", - func_obj=self.search_tool - ) - - # 启动后台任务 - await ctx.register_task( - self.background_sync(), - desc="后台数据同步" - ) -``` - -### 2. on_stop() - 插件停止钩子 - -**触发时机**:插件卸载或程序关闭前调用 - -**用途:** -- 关闭数据库连接 -- 清理临时文件 -- 注销 LLM 工具 -- 保存状态数据 - -**最佳实践:** -- 在 `on_stop()` 中释放 `on_start()` 注册的任务、监听器和外部资源 -- 把需要持久化的状态尽量提前落库,不要把关键保存逻辑完全依赖在进程退出瞬间 -- 始终把收到的 `ctx` 继续传给 `super().on_stop(ctx)`,不要手动丢掉它 - -**示例:** - -```python -class MyPlugin(Star): - async def on_stop(self, ctx: Any | None = None) -> None: - """插件停止时调用""" - # 保存状态 - await self.put_kv_data("last_shutdown", time.time()) - - # 确保 terminate 被调用 - await super().on_stop(ctx) -``` - -### 3. initialize() - 初始化钩子 - -**触发时机**:`on_start()` 内部自动调用 - -**用途:** -- 插件级别的初始化逻辑 -- 不依赖 Context 的初始化 - -**示例:** - -```python -class MyPlugin(Star): - async def initialize(self) -> None: - """初始化插件""" - self._cache = {} - self._counter = 0 -``` - -### 4. terminate() - 终止钩子 - -**触发时机**:`on_stop()` 内部自动调用 - -**用途:** -- 插件级别的清理逻辑 -- 不依赖 Context 的清理 - -**示例:** - -```python -class MyPlugin(Star): - async def terminate(self) -> None: - """清理插件资源""" - self._cache.clear() - self.state = "stopped" -``` - -### 5. on_error() - 错误处理钩子 - -**触发时机**:任何 Handler 执行抛出异常时 - -**参数:** -- `error: Exception` - 捕获的异常 -- `event` - 事件对象 -- `ctx` - 上下文对象 - -**示例:** - -```python -class MyPlugin(Star): - async def on_error(self, error: Exception, event, ctx) -> None: - """自定义错误处理""" - from astrbot_sdk.errors import AstrBotError - - if isinstance(error, AstrBotError): - await event.reply(error.hint or error.message) - elif isinstance(error, ValueError): - await event.reply(f"参数错误:{error}") - else: - await event.reply(f"发生错误: {type(error).__name__}") - - ctx.logger.error(f"Handler error: {error}", exc_info=error) -``` - ---- - -## Context 上下文使用 - -### 在 Handler 中访问 - -```python -class MyPlugin(Star): - @on_command("test") - async def test_handler(self, event: MessageEvent, ctx: Context): - # Context 通过参数注入 - await ctx.db.set("key", "value") - await event.reply("Done") -``` - -### 在生命周期钩子中访问 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 生命周期钩子中的 Context - config = await ctx.metadata.get_plugin_config() -``` - ---- - -## 插件元数据访问 - -### plugin.yaml 配置 - -```yaml -_schema_version: 2 -name: my_plugin -author: your_name -version: 1.0.0 -desc: 我的插件描述 -repo: https://github.com/user/repo -logo: logo.png - -runtime: - python: "3.12" - -components: - - class: main:MyPlugin - -support_platforms: - - aiocqhttp - - telegram - -astrbot_version: ">=4.13.0,<5.0.0" -``` - -### StarMetadata 类 - -插件元数据 dataclass,描述插件的基本信息。 - -```python -from astrbot_sdk import StarMetadata - -@dataclass -class StarMetadata: - name: str # 插件名称(唯一标识) - display_name: str # 显示名称 - description: str # 插件描述 - author: str # 作者 - version: str # 版本号 - enabled: bool = True # 是否启用 - support_platforms: list[str] # 支持的平台列表 - astrbot_version: str | None # 兼容的 AstrBot 版本范围 -``` - -**使用示例:** - -```python -from astrbot_sdk import Star, StarMetadata - -class MyPlugin(Star): - async def on_start(self, ctx): - # 获取当前插件元数据 - metadata: StarMetadata = await ctx.metadata.get_current_plugin() - - print(f"插件名称: {metadata.name}") - print(f"显示名称: {metadata.display_name}") - print(f"版本: {metadata.version}") - print(f"作者: {metadata.author}") - print(f"支持平台: {', '.join(metadata.support_platforms)}") - - # 检查兼容性 - if metadata.astrbot_version: - print(f"兼容版本: {metadata.astrbot_version}") -``` - -### PluginMetadata 类 - -`StarMetadata` 的别名,功能完全相同。 - -```python -from astrbot_sdk import PluginMetadata - -# PluginMetadata 是 StarMetadata 的别名 -# 两者可以互换使用 -metadata: PluginMetadata = await ctx.metadata.get_current_plugin() -``` - -**建议**:使用 `StarMetadata` 以符合 v4 SDK 的命名规范。 - -### 访问元数据 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 获取当前插件元数据 - my_metadata = await ctx.metadata.get_current_plugin() - print(f"Starting {my_metadata.name} v{my_metadata.version}") - - # 获取其他插件元数据 - other_metadata = await ctx.metadata.get_plugin("other_plugin") - if other_metadata: - print(f"依赖插件版本: {other_metadata.version}") -``` - ---- - -## 错误处理模式 - -### 标准错误类型 - -```python -from astrbot_sdk.errors import AstrBotError - -# 1. 输入无效错误 -raise AstrBotError.invalid_input( - "参数格式错误", - hint="请使用 JSON 格式" -) - -# 2. 能力未找到错误 -raise AstrBotError.capability_not_found("unknown_capability") - -# 3. 网络错误 -raise AstrBotError.network_error( - "连接超时", - hint="请检查网络连接" -) -``` - -### 在 Handler 中捕获错误 - -```python -class MyPlugin(Star): - @on_command("risky_operation") - async def risky(self, event: MessageEvent, ctx: Context): - try: - result = await self.risky_operation() - await event.reply(f"成功: {result}") - except ValueError as e: - await event.reply(f"参数错误: {e}") - except ConnectionError as e: - ctx.logger.error(f"Network error: {e}") - await event.reply("网络连接失败") - except Exception as e: - ctx.logger.exception("Unexpected error") - raise -``` - ---- - -## 最佳实践 - -### 1. 插件结构 - -``` -my_plugin/ -├── plugin.yaml # 插件配置 -├── main.py # 主入口 -├── handlers/ # 处理器模块 -├── utils/ # 工具函数 -├── requirements.txt # 可选的 Python 依赖 -└── README.md # 说明文档 -``` - -### 2. 插件模板 - -```python -""" -插件说明 -""" - -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command, on_message - -class MyPlugin(Star): - """插件类""" - - async def initialize(self) -> None: - """初始化""" - self._cache = {} - self._counter = 0 - - async def on_start(self, ctx) -> None: - """启动时调用""" - await super().on_start(ctx) - - # 加载配置 - config = await ctx.metadata.get_plugin_config() - self.setting = config.get("setting", "default") - - # 注册工具 - await ctx.register_llm_tool( - name="my_tool", - parameters_schema={...}, - desc="我的工具", - func_obj=self.my_tool - ) - - ctx.logger.info(f"{ctx.plugin_id} started") - - async def on_stop(self, ctx) -> None: - """停止时调用""" - # 保存状态 - await self.put_kv_data("counter", self._counter) - await super().on_stop(ctx) - ctx.logger.info(f"{ctx.plugin_id} stopped") - - @on_command("hello", aliases=["hi"]) - async def hello(self, event: MessageEvent, ctx: Context) -> None: - """打招呼命令""" - await event.reply(f"你好,{event.sender_name}!") - - async def my_tool(self, param: str) -> str: - """LLM 工具实现""" - return f"处理结果: {param}" -``` - -### 3. 配置管理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 获取配置 - config = await ctx.metadata.get_plugin_config() - - # 提供默认值 - self.timeout = config.get("timeout", 30) - self.max_retries = config.get("max_retries", 3) - self.debug = config.get("debug", False) - - # 验证必需配置 - if "api_key" not in config: - raise ValueError("缺少必需配置: api_key") - - self.api_key = config["api_key"] -``` - -### 4. 数据持久化 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 加载状态 - self.last_update = await self.get_kv_data("last_update", 0) - self.user_data = await self.get_kv_data("users", {}) - - async def save_state(self): - # 保存状态 - await self.put_kv_data("last_update", time.time()) - await self.put_kv_data("users", self.user_data) -``` - -### 5. 资源清理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 创建需要清理的资源 - self._session = aiohttp.ClientSession() - self._task = asyncio.create_task(self.background_task()) - - async def on_stop(self, ctx): - # 清理资源 - if hasattr(self, '_task'): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - if hasattr(self, '_session'): - await self._session.close() -``` diff --git a/astrbot-sdk/src/astrbot_sdk/docs/05_clients.md b/astrbot-sdk/src/astrbot_sdk/docs/05_clients.md deleted file mode 100644 index b5b30109c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/05_clients.md +++ /dev/null @@ -1,437 +0,0 @@ -# AstrBot SDK 客户端 API 参考文档 - -## 概述 - -本文档详细介绍 `astrbot_sdk/clients/` 目录下所有客户端的 API,包括方法签名、使用示例和注意事项。 - -## 目录 - -- [LLMClient - AI 对话客户端](#1-llmclient---ai-对话客户端) -- [MemoryClient - 记忆存储客户端](#2-memoryclient---记忆存储客户端) -- [DBClient - KV 数据库客户端](#3-dbclient---kv-数据库客户端) -- [PlatformClient - 平台消息客户端](#4-platformclient---平台消息客户端) -- [FileServiceClient - 文件服务客户端](#5-fileserviceclient---文件服务客户端) -- [HTTPClient - HTTP API 客户端](#6-httpclient---http-api-客户端) -- [MetadataClient - 插件元数据客户端](#7-metadataclient---插件元数据客户端) - ---- - -## 1. LLMClient - AI 对话客户端 - -### 导入 - -```python -from astrbot_sdk.clients import LLMClient, ChatMessage, LLMResponse -``` - -### 方法 - -#### chat() - -简单对话。 - -```python -reply = await ctx.llm.chat("你好,介绍一下自己") -``` - -#### chat_raw() - -获取完整响应。 - -```python -response = await ctx.llm.chat_raw("写一首诗", temperature=0.8) -print(f"Token 使用: {response.usage}") -``` - -#### stream_chat() - -流式对话。 - -```python -async for chunk in ctx.llm.stream_chat("讲一个故事"): - print(chunk, end="") -``` - ---- - -## 2. MemoryClient - 记忆存储客户端 - -### 导入 - -```python -from astrbot_sdk.clients import MemoryClient -``` - -### 方法 - -#### search() - -搜索记忆。默认在有 embedding provider 时执行 hybrid 检索。 - -```python -results = await ctx.memory.search("用户喜欢什么颜色", mode="hybrid", limit=5) -for item in results: - print(item["key"], item["score"], item["match_type"]) -``` - -#### save() - -保存记忆。 - -```python -await ctx.memory.save("user_pref", {"theme": "dark", "lang": "zh"}) -await ctx.memory.save( - "profile:alice", - {"name": "Alice", "embedding_text": "Alice 喜欢蓝色和海边"}, -) -``` - -#### get() - -获取记忆。 - -```python -pref = await ctx.memory.get("user_pref") -``` - -#### save_with_ttl() - -保存带过期时间的记忆。 - -```python -await ctx.memory.save_with_ttl( - "session_temp", - {"state": "waiting"}, - ttl_seconds=3600 -) -``` - -#### delete() - -删除记忆。 - -```python -await ctx.memory.delete("old_note") -``` - -#### stats() - -查看记忆索引状态。 - -```python -stats = await ctx.memory.stats() -print(stats["total_items"], stats.get("embedded_items"), stats.get("dirty_items")) -``` - ---- - -## 3. DBClient - KV 数据库客户端 - -### 导入 - -```python -from astrbot_sdk.clients import DBClient -``` - -### 方法 - -#### get() / set() - -基本读写。 - -```python -data = await ctx.db.get("user_settings") -await ctx.db.set("user_settings", {"theme": "dark"}) -``` - -#### delete() - -删除数据。 - -```python -await ctx.db.delete("user_settings") -``` - -#### list() - -列出键。 - -```python -keys = await ctx.db.list("user_") -``` - -#### get_many() / set_many() - -批量操作。 - -```python -values = await ctx.db.get_many(["user:1", "user:2"]) -await ctx.db.set_many({"user:1": {"name": "Alice"}, "user:2": {"name": "Bob"}}) -``` - -#### watch() - -监听变更。 - -```python -async for event in ctx.db.watch("user:"): - print(event["op"], event["key"]) -``` - ---- - -## 4. PlatformClient - 平台消息客户端 - -### 导入 - -```python -from astrbot_sdk.clients import PlatformClient -``` - -### 方法 - -#### send() - -发送文本消息。 - -```python -await ctx.platform.send("qq:group:123456", "大家好!") -``` - -#### send_image() - -发送图片。 - -```python -await ctx.platform.send_image(event.session_id, "https://example.com/image.png") -``` - -#### send_chain() - -发送消息链。 - -```python -from astrbot_sdk.message_components import Plain, Image - -chain = [Plain("文字"), Image(url="https://example.com/img.jpg")] -await ctx.platform.send_chain(event.session_id, chain) -``` - -#### send_by_id() - -通过 ID 发送。 - -```python -await ctx.platform.send_by_id( - platform_id="qq", - session_id="user123", - content="Hello", - message_type="private" -) -``` - -#### get_members() - -获取群成员。 - -```python -members = await ctx.platform.get_members("qq:group:123456") -``` - ---- - -## 5. FileServiceClient - 文件服务客户端 - -### 导入 - -```python -from astrbot_sdk.clients import FileServiceClient -``` - -### 方法 - -#### register_file() - -注册文件。 - -```python -token = await ctx.files.register_file("/path/to/file.jpg", timeout=3600) -``` - -#### handle_file() - -解析令牌。 - -```python -path = await ctx.files.handle_file(token) -``` - ---- - -## 6. HTTPClient - HTTP API 客户端 - -### 导入 - -```python -from astrbot_sdk.clients import HTTPClient -from astrbot_sdk.decorators import provide_capability -``` - -### 方法 - -#### register_api() - -注册 API。 - -```python -@provide_capability( - name="my_plugin.http_handler", - description="处理 HTTP 请求" -) -async def handle_http_request(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - -await ctx.http.register_api( - route="/my-api", - handler=handle_http_request, - methods=["GET", "POST"] -) -``` - -#### unregister_api() - -注销 API。 - -```python -await ctx.http.unregister_api("/my-api") -``` - -#### list_apis() - -列出 API。 - -```python -apis = await ctx.http.list_apis() -``` - ---- - -## 7. MetadataClient - 插件元数据客户端 - -### 导入 - -```python -from astrbot_sdk.clients import MetadataClient -``` - -### 方法 - -#### get_plugin() - -获取插件信息。 - -```python -plugin = await ctx.metadata.get_plugin("another_plugin") -if plugin: - print(f"插件: {plugin.display_name}") -``` - -#### list_plugins() - -列出所有插件。 - -```python -plugins = await ctx.metadata.list_plugins() -``` - -#### get_current_plugin() - -获取当前插件。 - -```python -current = await ctx.metadata.get_current_plugin() -``` - -#### get_plugin_config() - -获取配置。 - -```python -config = await ctx.metadata.get_plugin_config() -api_key = config.get("api_key") -``` - ---- - -## 客户端使用示例 - -### 1. 基本对话流程 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - reply = await ctx.llm.chat(event.message_content) - await ctx.platform.send(event.session_id, reply) -``` - -### 2. 带历史的对话 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - history_data = await ctx.memory.get(f"history:{event.session_id}") - history = history_data.get("messages", []) if history_data else [] - - reply = await ctx.llm.chat(event.message_content, history=history) - - history.append(ChatMessage(role="user", content=event.message_content)) - history.append(ChatMessage(role="assistant", content=reply)) - await ctx.memory.save(f"history:{event.session_id}", {"messages": history}) - - await ctx.platform.send(event.session_id, reply) -``` - -### 3. 使用数据库持久化 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - config = await ctx.db.get(f"user_config:{event.sender_id}") - - if not config: - config = {"theme": "light", "lang": "zh"} - await ctx.db.set(f"user_config:{event.sender_id}", config) - - reply = f"你的主题设置是: {config['theme']}" - await ctx.platform.send(event.session_id, reply) -``` - -### 4. 注册 Web API - -```python -@provide_capability( - name="my_plugin.get_status", - description="获取插件状态", -) -async def get_status(request_id: str, payload: dict, cancel_token): - return {"status": "running", "version": "1.0.0"} - -@on_command("setup_api") -async def setup_api(event: MessageEvent, ctx: Context): - await ctx.http.register_api( - route="/status", - handler=get_status, - methods=["GET"] - ) - await ctx.platform.send(event.session_id, "API 已注册") -``` - ---- - -## 注意事项 - -1. 所有客户端方法都是异步的 -2. 远程调用可能失败,建议使用 try-except -3. Memory 适合语义搜索,DB 适合精确匹配 -4. 文件操作使用 file service 注册令牌 -5. 平台标识使用 UMO 格式:`"platform:instance:session_id"` diff --git a/astrbot-sdk/src/astrbot_sdk/docs/06_error_handling.md b/astrbot-sdk/src/astrbot_sdk/docs/06_error_handling.md deleted file mode 100644 index 844b6ed3c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/06_error_handling.md +++ /dev/null @@ -1,625 +0,0 @@ -# AstrBot SDK 错误处理与调试指南 - -本文档详细介绍 SDK 中的错误处理机制、错误类型、调试技巧和常见问题解决方案。 - -## 目录 - -- [错误处理概述](#错误处理概述) -- [AstrBotError 错误体系](#astrboterror-错误体系) -- [错误码参考](#错误码参考) -- [错误处理模式](#错误处理模式) -- [调试技巧](#调试技巧) -- [常见问题](#常见问题) - ---- - -## 错误处理概述 - -AstrBot SDK 使用统一的错误体系 `AstrBotError`,支持跨进程传递(通过 to_payload/from_payload 序列化)。 - -### 错误处理流程 - -``` -1. 运行时抛出 AstrBotError 子类或实例 -2. 错误被捕获并序列化为 payload -3. 跨进程传输后反序列化 -4. 在 on_error 钩子中统一处理 -``` - -### 基本使用 - -```python -from astrbot_sdk.errors import AstrBotError, ErrorCodes - -# 抛出错误 -raise AstrBotError.invalid_input("参数不能为空") - -# 捕获并处理 -try: - await some_operation() -except AstrBotError as e: - if e.retryable: - # 可重试的错误 - await retry() - else: - # 不可重试的错误 - await event.reply(e.hint or e.message) -``` - ---- - -## AstrBotError 错误体系 - -### AstrBotError 类 - -```python -@dataclass(slots=True) -class AstrBotError(Exception): - code: str # 错误码 - message: str # 错误消息(面向开发者) - hint: str = "" # 用户提示(面向终端用户) - retryable: bool = False # 是否可重试 - docs_url: str = "" # 文档链接 - details: dict[str, Any] | None = None # 详细信息 -``` - -### 工厂方法 - -#### 1. invalid_input - 输入无效错误 - -**场景**:参数格式错误、缺少必需参数等 - -```python -raise AstrBotError.invalid_input( - message="参数格式错误", - hint="请使用 JSON 格式", - docs_url="https://docs.example.com/api" -) -``` - -**属性**: -- `retryable`: False -- 应该在修复输入后重试 - -#### 2. capability_not_found - 能力未找到 - -**场景**:调用的 capability 不存在或未注册 - -```python -raise AstrBotError.capability_not_found("unknown_capability") -``` - -**属性**: -- `retryable`: False -- 通常是配置或版本不匹配问题 - -#### 3. network_error - 网络错误 - -**场景**:连接超时、DNS 解析失败等 - -```python -raise AstrBotError.network_error( - message="连接超时", - hint="请检查网络连接后重试" -) -``` - -**属性**: -- `retryable`: True -- 通常可以重试 - -#### 4. internal_error - 内部错误 - -**场景**:SDK 或 Core 内部错误 - -```python -raise AstrBotError.internal_error( - message="数据库连接失败", - hint="请联系插件作者" -) -``` - -**属性**: -- `retryable`: False -- 需要开发者介入 - -#### 5. cancelled - 取消错误 - -**场景**:操作被取消 - -```python -raise AstrBotError.cancelled("用户取消了操作") -``` - -**属性**: -- `retryable`: False - -#### 6. protocol_version_mismatch - 协议版本不匹配 - -**场景**:SDK 和 Core 协议版本不兼容 - -```python -raise AstrBotError.protocol_version_mismatch("协议版本不匹配: v4 vs v5") -``` - -**属性**: -- `retryable`: False -- 需要升级 SDK 或 Core - ---- - -## 错误码参考 - -### 不可立即自动重试错误(retryable=False) - -这些错误不适合框架做“立刻重试”的自动恢复;其中 `RATE_LIMITED` 和 -`COOLDOWN_ACTIVE` 仍然可以在等待窗口结束后由用户或插件重新发起调用。 - -| 错误码 | 说明 | 处理方式 | -|--------|------|----------| -| `LLM_NOT_CONFIGURED` | LLM 未配置 | 配置 LLM Provider | -| `CAPABILITY_NOT_FOUND` | 能力未找到 | 检查 capability 名称 | -| `PERMISSION_DENIED` | 权限不足 | 检查用户权限 | -| `LLM_ERROR` | LLM 错误 | 查看详细错误信息 | -| `INVALID_INPUT` | 输入无效 | 修正输入参数 | -| `CANCELLED` | 操作被取消 | 无需处理 | -| `PROTOCOL_VERSION_MISMATCH` | 协议版本不匹配 | 升级 SDK | -| `PROTOCOL_ERROR` | 协议错误 | 检查实现 | -| `INTERNAL_ERROR` | 内部错误 | 联系开发者 | -| `RATE_LIMITED` | 速率限制 | 等待速率窗口结束后再重试 | -| `COOLDOWN_ACTIVE` | 冷却中 | 等待冷却结束后再重试 | - -### 可重试错误(retryable=True) - -| 错误码 | 说明 | 处理方式 | -|--------|------|----------| -| `CAPABILITY_TIMEOUT` | 能力调用超时 | 重试或增加超时时间 | -| `NETWORK_ERROR` | 网络错误 | 重试 | -| `LLM_TEMPORARY_ERROR` | LLM 临时错误 | 重试 | - ---- - -## 对话相关异常 - -### ConversationClosed - -对话已关闭异常。 - -**场景**:会话被显式关闭或超时时抛出 - -```python -from astrbot_sdk.conversation import ConversationClosed - -@conversation_command("demo") -async def demo_handler(self, event, ctx, session): - try: - # 处理对话... - session.close() # 关闭会话 - except ConversationClosed: - await event.reply("对话已结束") -``` - -**属性**: -- 继承自 `RuntimeError` -- 表示对话会话已结束,无法再接收消息 - -### ConversationReplaced - -对话被替换异常。 - -**场景**:用户开始新对话,当前对话被替换时抛出 - -```python -from astrbot_sdk.conversation import ConversationReplaced - -@conversation_command("survey") -async def survey_handler(self, event, ctx, session): - try: - # 处理对话... - pass - except ConversationReplaced: - # 用户开始了新对话 - await event.reply("已切换到新对话") -``` - -**属性**: -- 继承自 `RuntimeError` -- 表示当前对话被新对话替换 - ---- - -## 错误处理模式 - -### 模式 1:基本错误处理 - -```python -@on_command("risky") -async def risky_handler(self, event: MessageEvent, ctx: Context): - try: - result = await risky_operation() - await event.reply(f"成功: {result}") - except AstrBotError as e: - # SDK 错误包含用户友好的提示 - await event.reply(e.hint or e.message) - ctx.logger.error(f"操作失败: {e}") - except Exception as e: - # 未知错误 - ctx.logger.exception("未知错误") - await event.reply("操作失败,请稍后重试") -``` - -### 模式 2:分层错误处理 - -```python -async def fetch_data(ctx: Context, url: str) -> dict: - """获取数据,处理网络错误""" - try: - return await ctx.http.get(url) - except AstrBotError as e: - if e.code == ErrorCodes.NETWORK_ERROR: - # 网络错误可以重试 - ctx.logger.warning(f"网络错误,重试: {e}") - await asyncio.sleep(1) - return await ctx.http.get(url) - raise - -@on_command("data") -async def data_handler(self, event: MessageEvent, ctx: Context): - try: - data = await self.fetch_data(ctx, "https://api.example.com/data") - await event.reply(f"数据: {data}") - except AstrBotError as e: - if e.retryable: - await event.reply(f"暂时无法获取数据,请稍后重试") - else: - await event.reply(f"获取数据失败: {e.hint}") -``` - -### 模式 3:on_error 生命周期钩子 - -```python -class MyPlugin(Star): - async def on_error(self, error: Exception, event, ctx) -> None: - """统一错误处理""" - from astrbot_sdk.errors import AstrBotError - - if isinstance(error, AstrBotError): - # SDK 错误 - if error.code == ErrorCodes.RATE_LIMITED: - await event.reply("操作过于频繁,请稍后再试") - elif error.code == ErrorCodes.PERMISSION_DENIED: - await event.reply("你没有权限执行此操作") - else: - await event.reply(error.hint or "操作失败") - elif isinstance(error, ValueError): - # 参数错误 - await event.reply(f"参数错误: {error}") - else: - # 未知错误 - ctx.logger.exception("未处理的错误") - await event.reply("发生未知错误,请联系管理员") -``` - -### 模式 4:重试机制 - -```python -from astrbot_sdk.errors import AstrBotError, ErrorCodes - -async def with_retry( - operation, - max_retries: int = 3, - delay: float = 1.0 -): - """带重试的操作""" - last_error = None - - for attempt in range(max_retries): - try: - return await operation() - except AstrBotError as e: - last_error = e - if not e.retryable: - raise # 不可重试错误直接抛出 - - ctx.logger.warning(f"第 {attempt + 1} 次尝试失败: {e}") - if attempt < max_retries - 1: - await asyncio.sleep(delay * (attempt + 1)) # 指数退避 - - raise last_error - -# 使用 -@on_command("fetch") -async def fetch_handler(self, event: MessageEvent, ctx: Context): - try: - result = await with_retry( - lambda: ctx.llm.chat("生成内容"), - max_retries=3 - ) - await event.reply(result) - except AstrBotError as e: - await event.reply(f"请求失败: {e.hint}") -``` - -### 模式 5:取消处理 - -```python -@on_command("long_task") -async def long_task_handler(self, event: MessageEvent, ctx: Context): - try: - for i in range(100): - # 检查是否取消 - ctx.cancel_token.raise_if_cancelled() - - await do_work(i) - await asyncio.sleep(0.1) - - await event.reply("任务完成") - except asyncio.CancelledError: - await event.reply("任务已取消") - raise # 重新抛出以便框架处理 - except AstrBotError as e: - if e.code == ErrorCodes.CANCELLED: - await event.reply("操作已取消") - else: - raise -``` - ---- - -## 调试技巧 - -### 1. 启用详细日志 - -```python -# 在插件中记录详细日志 -@on_command("debug") -async def debug_handler(self, event: MessageEvent, ctx: Context): - ctx.logger.debug(f"收到消息: {event.text}") - ctx.logger.debug(f"用户ID: {event.user_id}") - ctx.logger.debug(f"会话ID: {event.session_id}") - ctx.logger.debug(f"平台: {event.platform}") - - # 记录组件信息 - components = event.get_messages() - for comp in components: - ctx.logger.debug(f"组件: {comp.type} - {comp}") -``` - -### 2. 使用测试框架调试 - -```python -from astrbot_sdk.testing import PluginTestHarness - -async def test_with_debug(): - harness = PluginTestHarness() - plugin = harness.load_plugin("my_plugin.main:MyPlugin") - - # 启用详细日志 - harness.enable_debug_logging() - - # 模拟事件 - result = await harness.simulate_command("/hello") - print(f"结果: {result}") - - # 查看调用历史 - for call in harness.get_call_history(): - print(f"调用: {call}") -``` - -### 3. 使用 PDB 调试 - -```python -import pdb - -@on_command("debug") -async def debug_handler(self, event: MessageEvent, ctx: Context): - # 设置断点 - pdb.set_trace() - - result = await ctx.llm.chat("测试") - await event.reply(result) -``` - -### 4. 记录完整错误信息 - -```python -import traceback - -@on_command("risky") -async def risky_handler(self, event: MessageEvent, ctx: Context): - try: - result = await risky_operation() - await event.reply(f"成功: {result}") - except Exception as e: - # 记录完整堆栈 - ctx.logger.error(f"错误: {e}") - ctx.logger.error(f"堆栈: {traceback.format_exc()}") - - # 发送简化信息给用户 - await event.reply("操作失败,请查看日志") -``` - -### 5. 使用 Context 的 cancel_token 调试 - -```python -@on_command("timeout_test") -async def timeout_test(self, event: MessageEvent, ctx: Context): - ctx.logger.info(f"取消状态: {ctx.cancel_token.cancelled}") - - try: - # 长时间运行的操作 - for i in range(10): - ctx.logger.debug(f"步骤 {i}, 取消状态: {ctx.cancel_token.cancelled}") - ctx.cancel_token.raise_if_cancelled() - await asyncio.sleep(1) - - await event.reply("完成") - except asyncio.CancelledError: - ctx.logger.info("操作被取消") - raise -``` - ---- - -## 常见问题 - -### Q1: 如何处理 "CAPABILITY_NOT_FOUND" 错误? - -**原因**:调用的 capability 不存在或未注册 - -**解决方案**: -```python -# 检查 Core 版本是否支持 -# 确认 capability 名称正确 -# 检查插件是否正确加载 - -try: - result = await ctx._proxy.call("unknown.capability", {}) -except AstrBotError as e: - if e.code == ErrorCodes.CAPABILITY_NOT_FOUND: - ctx.logger.error("当前 AstrBot 版本不支持此功能") - await event.reply("请升级 AstrBot 到最新版本") -``` - -### Q2: 如何处理速率限制? - -**解决方案**: -```python -from astrbot_sdk.errors import ErrorCodes - -@on_command("api_call") -async def api_call_handler(self, event: MessageEvent, ctx: Context): - try: - result = await call_api() - await event.reply(result) - except AstrBotError as e: - if e.code == ErrorCodes.RATE_LIMITED: - # 获取重试时间(如果有) - retry_after = e.details.get("retry_after", 60) - await event.reply(f"操作过于频繁,请 {retry_after} 秒后再试") - else: - raise -``` - -### Q3: 如何区分用户错误和系统错误? - -**解决方案**: -```python -@on_command("process") -async def process_handler(self, event: MessageEvent, ctx: Context): - try: - result = await process(event.text) - await event.reply(result) - except AstrBotError as e: - if e.code in { - ErrorCodes.INVALID_INPUT, - ErrorCodes.PERMISSION_DENIED - }: - # 用户错误,直接提示 - await event.reply(e.hint or e.message) - else: - # 系统错误,记录并提示 - ctx.logger.error(f"系统错误: {e}") - await event.reply("系统错误,请稍后重试") -``` - -### Q4: 如何在 on_error 中避免无限循环? - -**注意**:如果 `on_error` 中抛出异常,会导致递归调用 - -**解决方案**: -```python -class MyPlugin(Star): - async def on_error(self, error: Exception, event, ctx) -> None: - try: - # 错误处理逻辑 - await event.reply("发生错误") - except Exception as e: - # 避免递归,只记录不回复 - ctx.logger.exception("on_error 失败") -``` - -### Q5: 如何调试跨进程通信问题? - -**解决方案**: -```python -# 启用 SDK 调试日志 -import logging -logging.getLogger("astrbot_sdk").setLevel(logging.DEBUG) - -# 在关键位置添加日志 -@on_command("debug_comm") -async def debug_comm_handler(self, event: MessageEvent, ctx: Context): - ctx.logger.debug("开始调用 capability") - - try: - result = await ctx._proxy.call("test.capability", {"key": "value"}) - ctx.logger.debug(f"调用成功: {result}") - except Exception as e: - ctx.logger.error(f"调用失败: {e}") - raise -``` - ---- - -## 最佳实践 - -### 1. 始终处理可重试错误 - -```python -# 好的做法 -async def reliable_operation(ctx): - max_retries = 3 - for i in range(max_retries): - try: - return await ctx.llm.chat("prompt") - except AstrBotError as e: - if e.retryable and i < max_retries - 1: - await asyncio.sleep(2 ** i) # 指数退避 - else: - raise -``` - -### 2. 提供用户友好的错误提示 - -```python -# 好的做法 -try: - result = await operation() -except AstrBotError as e: - # 使用 SDK 提供的 hint - await event.reply(e.hint or "操作失败,请稍后重试") -``` - -### 3. 区分日志级别 - -```python -# 好的做法 -try: - result = await operation() -except AstrBotError as e: - if e.retryable: - ctx.logger.warning(f"临时错误: {e}") - else: - ctx.logger.error(f"严重错误: {e}") -``` - -### 4. 在 on_stop 中处理清理错误 - -```python -class MyPlugin(Star): - async def on_stop(self, ctx): - try: - await self.cleanup() - except Exception as e: - # 清理错误不应阻止停止流程 - ctx.logger.error(f"清理失败: {e}") -``` - ---- - -## 相关文档 - -- [Context API 参考](./01_context_api.md) -- [Star 类与生命周期](./04_star_lifecycle.md) -- [高级主题](./07_advanced_topics.md) diff --git a/astrbot-sdk/src/astrbot_sdk/docs/07_advanced_topics.md b/astrbot-sdk/src/astrbot_sdk/docs/07_advanced_topics.md deleted file mode 100644 index d7805e257..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/07_advanced_topics.md +++ /dev/null @@ -1,575 +0,0 @@ -# AstrBot SDK 高级主题 - -本文档介绍 AstrBot SDK 的高级用法,包括并发处理、性能优化、安全最佳实践和架构设计。 - -## 目录 - -- [并发处理](#并发处理) -- [性能优化](#性能优化) -- [安全最佳实践](#安全最佳实践) -- [架构设计模式](#架构设计模式) -- [高级客户端用法](#高级客户端用法) - ---- - -## 并发处理 - -### asyncio 基础 - -SDK 完全基于 asyncio 构建,所有操作都是异步的。 - -```python -import asyncio -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("concurrent") - async def concurrent_handler(self, event: MessageEvent, ctx: Context): - # 并发执行多个操作 - tasks = [ - ctx.llm.chat("任务1"), - ctx.llm.chat("任务2"), - ctx.llm.chat("任务3"), - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, result in enumerate(results): - if isinstance(result, Exception): - await event.reply(f"任务{i+1}失败: {result}") - else: - await event.reply(f"任务{i+1}结果: {result}") -``` - -### 并发限制 - -避免同时发起过多请求: - -```python -import asyncio -from asyncio import Semaphore - -class MyPlugin(Star): - def __init__(self): - # 限制并发数 - self._semaphore = Semaphore(5) - - async def limited_operation(self, ctx, prompt): - async with self._semaphore: - return await ctx.llm.chat(prompt) - - @on_command("batch") - async def batch_handler(self, event: MessageEvent, ctx: Context): - prompts = ["任务1", "任务2", "任务3", "任务4", "任务5"] - - # 使用 semaphore 限制并发 - tasks = [self.limited_operation(ctx, p) for p in prompts] - results = await asyncio.gather(*tasks, return_exceptions=True) - - await event.reply(f"完成 {len(results)} 个任务") -``` - -### 取消处理 - -正确处理操作取消: - -```python -@on_command("cancelable") -async def cancelable_handler(self, event: MessageEvent, ctx: Context): - try: - # 长时间运行的操作 - for i in range(100): - # 检查是否被取消 - ctx.cancel_token.raise_if_cancelled() - - await asyncio.sleep(0.1) - - if i % 10 == 0: - await event.reply(f"进度: {i}%") - - await event.reply("完成!") - except asyncio.CancelledError: - await event.reply("操作已取消") - raise # 重新抛出以便框架处理 -``` - -### 锁和同步 - -保护共享资源: - -```python -import asyncio - -class MyPlugin(Star): - def __init__(self): - self._lock = asyncio.Lock() - self._counter = 0 - - async def increment(self): - async with self._lock: - # 临界区 - current = self._counter - await asyncio.sleep(0.1) # 模拟操作 - self._counter = current + 1 - return self._counter - - @on_command("count") - async def count_handler(self, event: MessageEvent, ctx: Context): - count = await self.increment() - await event.reply(f"当前计数: {count}") -``` - ---- - -## 性能优化 - -### 1. 连接池 - -复用 HTTP 连接: - -```python -import aiohttp - -class MyPlugin(Star): - async def on_start(self, ctx): - # 创建连接池 - self._session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(limit=100, limit_per_host=20) - ) - - async def on_stop(self, ctx): - await self._session.close() - - async def fetch_data(self, url): - # 复用连接 - async with self._session.get(url) as response: - return await response.json() -``` - -### 2. 缓存策略 - -使用内存缓存减少重复计算: - -```python -from functools import lru_cache -import asyncio - -class MyPlugin(Star): - def __init__(self): - self._cache = {} - self._cache_lock = asyncio.Lock() - - async def get_cached_data(self, key, ttl=300): - async with self._cache_lock: - if key in self._cache: - data, timestamp = self._cache[key] - if asyncio.get_event_loop().time() - timestamp < ttl: - return data - - # 从数据库获取 - data = await self.fetch_from_db(key) - - async with self._cache_lock: - self._cache[key] = (data, asyncio.get_event_loop().time()) - - return data - - async def invalidate_cache(self, key): - async with self._cache_lock: - self._cache.pop(key, None) -``` - -### 3. 批处理 - -批量操作减少网络往返: - -```python -@on_command("batch_db") -async def batch_db_handler(self, event: MessageEvent, ctx: Context): - # 批量获取 - keys = [f"user:{i}" for i in range(100)] - values = await ctx.db.get_many(keys) - - # 批量设置 - updates = {f"user:{i}": {"updated": True} for i in range(100)} - await ctx.db.set_many(updates) - - await event.reply(f"更新了 {len(updates)} 条记录") -``` - -### 4. 流式处理 - -使用流式 API 处理大数据: - -```python -@on_command("stream") -async def stream_handler(self, event: MessageEvent, ctx: Context): - # 流式 LLM 响应 - message = await event.reply("正在生成...") - - full_text = "" - async for chunk in ctx.llm.stream_chat("写一个很长的故事"): - full_text += chunk - # 每 100 个字符更新一次 - if len(full_text) % 100 < 10: - await message.edit(full_text + "...") - - await message.edit(full_text) -``` - -### 5. 懒加载 - -延迟初始化资源: - -```python -class MyPlugin(Star): - def __init__(self): - self._expensive_resource = None - self._resource_lock = asyncio.Lock() - - async def get_resource(self): - if self._expensive_resource is None: - async with self._resource_lock: - if self._expensive_resource is None: - # 昂贵的初始化 - self._expensive_resource = await self.init_resource() - return self._expensive_resource -``` - ---- - -## 安全最佳实践 - -### 1. 输入验证 - -始终验证用户输入: - -```python -import re -from astrbot_sdk.errors import AstrBotError - -@on_command("search") -async def search_handler(self, event: MessageEvent, ctx: Context, query: str): - # 验证输入长度 - if len(query) > 1000: - raise AstrBotError.invalid_input("查询过长,最多 1000 字符") - - # 验证输入内容 - if not re.match(r'^[\w\s\-]+$', query): - raise AstrBotError.invalid_input("查询包含非法字符") - - # 执行搜索 - result = await self.search(query) - await event.reply(result) -``` - -### 2. 防止注入攻击 - -```python -# 危险的代码 -# await ctx.db.set(f"user:{event.user_id}", eval(user_input)) - -# 安全的代码 -import json - -@on_command("save") -async def save_handler(self, event: MessageEvent, ctx: Context, data: str): - try: - # 使用 JSON 解析而不是 eval - parsed = json.loads(data) - await ctx.db.set(f"user:{event.user_id}", parsed) - except json.JSONDecodeError: - raise AstrBotError.invalid_input("无效的 JSON 格式") -``` - -### 3. 敏感信息处理 - -```python -import os - -class MyPlugin(Star): - async def on_start(self, ctx): - config = await ctx.metadata.get_plugin_config() - - # 从配置或环境变量获取敏感信息 - self.api_key = config.get("api_key") or os.getenv("MY_PLUGIN_API_KEY") - - if not self.api_key: - raise ValueError("缺少 API Key") - - # 不要在日志中打印敏感信息 - ctx.logger.info("API Key 已配置") - # 不要: ctx.logger.info(f"API Key: {self.api_key}") -``` - -### 4. 权限检查 - -```python -from astrbot_sdk.decorators import require_admin - -class MyPlugin(Star): - @on_command("admin_only") - @require_admin - async def admin_only(self, event: MessageEvent, ctx: Context): - await event.reply("管理员命令执行成功") - - async def check_permission(self, event, required_role): - # 自定义权限检查 - if not event.is_admin() and required_role == "admin": - raise AstrBotError.invalid_input("需要管理员权限") -``` - -### 5. 速率限制 - -```python -from astrbot_sdk.decorators import rate_limit - -class MyPlugin(Star): - @on_command("expensive") - @rate_limit( - limit=5, - window=3600, - scope="user", - message="每小时只能调用 5 次" - ) - async def expensive_operation(self, event: MessageEvent, ctx: Context): - # 昂贵的操作 - result = await ctx.llm.chat("复杂任务", model="gpt-4") - await event.reply(result) -``` - ---- - -## 架构设计模式 - -### 1. 分层架构 - -``` -my_plugin/ -├── __init__.py -├── main.py # 插件入口 -├── handlers/ # 处理器层 -│ ├── __init__.py -│ ├── commands.py # 命令处理器 -│ └── messages.py # 消息处理器 -├── services/ # 业务逻辑层 -│ ├── __init__.py -│ ├── user_service.py -│ └── data_service.py -├── models/ # 数据模型层 -│ ├── __init__.py -│ └── user.py -└── utils/ # 工具层 - ├── __init__.py - └── helpers.py -``` - -### 2. 依赖注入 - -```python -class UserService: - def __init__(self, ctx: Context): - self._ctx = ctx - - async def get_user(self, user_id: str): - return await self._ctx.db.get(f"user:{user_id}") - -class MyPlugin(Star): - async def on_start(self, ctx): - # 注入依赖 - self._user_service = UserService(ctx) - - @on_command("profile") - async def profile_handler(self, event: MessageEvent, ctx: Context): - user = await self._user_service.get_user(event.user_id) - await event.reply(f"用户信息: {user}") -``` - -### 3. 事件驱动架构 - -```python -class MyPlugin(Star): - def __init__(self): - self._event_handlers = {} - - def register_handler(self, event_type, handler): - if event_type not in self._event_handlers: - self._event_handlers[event_type] = [] - self._event_handlers[event_type].append(handler) - - async def emit_event(self, event_type, data): - handlers = self._event_handlers.get(event_type, []) - for handler in handlers: - try: - await handler(data) - except Exception as e: - self.logger.error(f"事件处理失败: {e}") -``` - -### 4. 状态机模式 - -```python -from enum import Enum, auto - -class ConversationState(Enum): - IDLE = auto() - WAITING_INPUT = auto() - PROCESSING = auto() - -class MyPlugin(Star): - def __init__(self): - self._states = {} - - async def get_state(self, session_id): - return self._states.get(session_id, ConversationState.IDLE) - - async def set_state(self, session_id, state): - self._states[session_id] = state - - @on_message() - async def handle_message(self, event: MessageEvent, ctx: Context): - state = await self.get_state(event.session_id) - - if state == ConversationState.IDLE: - await self.handle_idle(event, ctx) - elif state == ConversationState.WAITING_INPUT: - await self.handle_waiting(event, ctx) -``` - ---- - -## 高级客户端用法 - -### 1. ProviderManagerClient - -```python -from astrbot_sdk import Star, Context -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("switch_provider") - async def switch_provider(self, event: MessageEvent, ctx: Context): - # 列出所有 Provider - providers = await ctx.provider_manager.get_insts() - - # 切换 Provider - await ctx.provider_manager.set_provider( - provider_id="gpt-4", - provider_type="chat_completion" - ) - - # 监听 Provider 变更 - async for change in ctx.provider_manager.watch_changes(): - ctx.logger.info(f"Provider 变更: {change.provider_id}") -``` - -### 2. 平台管理 - -```python -@on_command("platform_info") -async def platform_info(self, event: MessageEvent, ctx: Context): - # 获取平台实例 - platform = await ctx.get_platform_inst("qq:instance1") - - if platform: - await platform.refresh() - await event.reply( - f"平台: {platform.name}\n" - f"状态: {platform.status}\n" - f"错误数: {len(platform.errors)}" - ) -``` - -### 3. 高级 LLM 用法 - -```python -from astrbot_sdk.llm.entities import ProviderRequest - -@on_command("advanced_llm") -async def advanced_llm(self, event: MessageEvent, ctx: Context): - # 使用 ProviderRequest 进行精细控制 - request = ProviderRequest( - prompt="生成内容", - system_prompt="你是一个助手", - temperature=0.7, - max_tokens=2000 - ) - - # 使用工具循环 Agent - response = await ctx.tool_loop_agent( - request=request, - tool_names=["search", "calculate"] - ) - - await event.reply(response.text) -``` - -### 4. 会话管理 - -```python -from astrbot_sdk.conversation import ConversationSession - -@on_command("conversation") -async def conversation_handler(self, event: MessageEvent, ctx: Context): - # 创建会话 - session = ConversationSession( - session_id=event.session_id, - conversation_id="conv_123" - ) - - # 使用会话上下文 - async with session: - await session.send("开始对话") - response = await session.receive() - await session.send(f"收到: {response}") -``` - ---- - -## 性能监控 - -### 1. 添加性能指标 - -```python -import time - -class MyPlugin(Star): - async def monitored_operation(self, operation, *args, **kwargs): - start = time.time() - try: - result = await operation(*args, **kwargs) - return result - finally: - duration = time.time() - start - self.logger.info(f"操作耗时: {duration:.2f}s") - - @on_command("slow") - async def slow_handler(self, event: MessageEvent, ctx: Context): - result = await self.monitored_operation( - ctx.llm.chat, - "复杂查询" - ) - await event.reply(result) -``` - -### 2. 内存监控 - -```python -import sys -import gc - -class MyPlugin(Star): - def log_memory_usage(self): - # 获取内存使用 - gc.collect() - objects = gc.get_objects() - self.logger.debug(f"当前对象数: {len(objects)}") -``` - ---- - -## 相关文档 - -- [错误处理与调试](./06_error_handling.md) -- [测试指南](./08_testing_guide.md) -- [安全检查清单](./11_security_checklist.md) diff --git a/astrbot-sdk/src/astrbot_sdk/docs/08_testing_guide.md b/astrbot-sdk/src/astrbot_sdk/docs/08_testing_guide.md deleted file mode 100644 index b4e172d1f..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/08_testing_guide.md +++ /dev/null @@ -1,609 +0,0 @@ -# AstrBot SDK 测试指南 - -本文档介绍如何测试 AstrBot SDK 插件,包括单元测试、集成测试和使用测试框架。 - -## 目录 - -- [测试概述](#测试概述) -- [测试框架](#测试框架) -- [单元测试](#单元测试) -- [集成测试](#集成测试) -- [Mock 使用](#mock-使用) -- [测试最佳实践](#测试最佳实践) - ---- - -## 测试概述 - -### 为什么需要测试? - -1. **确保功能正确性**:验证插件按预期工作 -2. **防止回归**:修改代码时不破坏现有功能 -3. **文档化**:测试用例展示了如何使用代码 -4. **提高信心**:放心地重构和优化代码 - -### 测试类型 - -``` -单元测试 ──→ 集成测试 ──→ 端到端测试 -(最快) (中等) (最慢) -``` - ---- - -## 测试框架 - -### 安装测试依赖 - -```bash -pip install pytest pytest-asyncio pytest-cov -``` - -### 配置 pytest - -```python -# conftest.py -import pytest -from astrbot_sdk.testing import PluginTestHarness - -@pytest.fixture -async def harness(): - """提供测试 harness""" - h = PluginTestHarness() - yield h - await h.cleanup() - -@pytest.fixture -async def plugin(harness): - """加载插件""" - return await harness.load_plugin("my_plugin.main:MyPlugin") -``` - ---- - -## 单元测试 - -### 测试命令处理器 - -```python -import pytest -from astrbot_sdk.testing import PluginTestHarness - -@pytest.mark.asyncio -async def test_hello_command(): - """测试 hello 命令""" - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 模拟命令调用 - result = await harness.simulate_command("/hello") - - # 验证结果 - assert result.text == "Hello, World!" - - await harness.cleanup() -``` - -### 测试消息处理器 - -```python -@pytest.mark.asyncio -async def test_message_handler(): - """测试消息处理器""" - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 模拟消息 - result = await harness.simulate_message( - text="你好", - user_id="12345", - session_id="session_1" - ) - - # 验证响应 - assert "你好" in result.text - - await harness.cleanup() -``` - -### 测试装饰器 - -```python -@pytest.mark.asyncio -async def test_rate_limit(): - """测试速率限制""" - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 第一次调用应该成功 - result1 = await harness.simulate_command("/limited") - assert result1.success - - # 快速第二次调用应该被限制 - result2 = await harness.simulate_command("/limited") - assert result2.error.code == "rate_limited" - - await harness.cleanup() -``` - ---- - -## 集成测试 - -### 测试数据库操作 - -```python -@pytest.mark.asyncio -async def test_database_operations(): - """测试数据库操作""" - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 模拟事件以获取 ctx - event = harness.create_mock_event(text="test") - - # 设置数据 - await plugin.save_user_data( - event, - event.ctx, - user_id="123", - data={"name": "Alice"} - ) - - # 读取数据 - data = await plugin.get_user_data( - event, - event.ctx, - user_id="123" - ) - - assert data["name"] == "Alice" - - await harness.cleanup() -``` - -### 测试 LLM 调用 - -```python -@pytest.mark.asyncio -async def test_llm_integration(): - """测试 LLM 调用""" - harness = PluginTestHarness() - - # 配置 mock LLM 响应 - harness.mock_llm_response("模拟的 LLM 回复") - - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 调用需要 LLM 的命令 - result = await harness.simulate_command("/ask 问题") - - assert "模拟的 LLM 回复" in result.text - - await harness.cleanup() -``` - -### 测试平台发送 - -```python -@pytest.mark.asyncio -async def test_platform_send(): - """测试平台消息发送""" - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - # 模拟命令 - await harness.simulate_command("/broadcast 大家好") - - # 验证发送记录 - sent_messages = harness.get_sent_messages() - assert len(sent_messages) >= 1 - assert "大家好" in sent_messages[0].text - - await harness.cleanup() -``` - ---- - -## Mock 使用 - -### Mock Context - -```python -from unittest.mock import AsyncMock, MagicMock -from astrbot_sdk import Context - -@pytest.fixture -def mock_ctx(): - """创建 mock Context""" - ctx = MagicMock(spec=Context) - - # Mock LLM 客户端 - ctx.llm = AsyncMock() - ctx.llm.chat.return_value = "Mocked response" - - # Mock DB 客户端 - ctx.db = AsyncMock() - ctx.db.get.return_value = {"key": "value"} - - # Mock Logger - ctx.logger = MagicMock() - - return ctx - -@pytest.mark.asyncio -async def test_with_mock_ctx(mock_ctx): - """使用 mock Context 测试""" - plugin = MyPlugin() - - result = await plugin.some_method(mock_ctx) - - # 验证调用 - mock_ctx.llm.chat.assert_called_once() - assert result == "expected" -``` - -### Mock 事件 - -```python -from astrbot_sdk import MessageEvent - -@pytest.fixture -def mock_event(): - """创建 mock 事件""" - event = MagicMock(spec=MessageEvent) - event.text = "测试消息" - event.user_id = "12345" - event.session_id = "session_1" - event.platform = "qq" - - # Mock 回复方法 - event.reply = AsyncMock() - - return event - -@pytest.mark.asyncio -async def test_with_mock_event(mock_event, mock_ctx): - """使用 mock 事件测试""" - plugin = MyPlugin() - - await plugin.handle_message(mock_event, mock_ctx) - - # 验证回复 - mock_event.reply.assert_called_once() -``` - -### Mock 时间 - -```python -import time -from unittest.mock import patch - -@pytest.mark.asyncio -async def test_with_mock_time(): - """使用 mock 时间测试""" - with patch('time.time', return_value=1234567890): - result = await plugin.time_sensitive_operation() - - assert result.timestamp == 1234567890 -``` - -### Mock 外部 API - -```python -import aiohttp -from aioresponses import aioresponses - -@pytest.mark.asyncio -async def test_external_api(): - """测试外部 API 调用""" - with aioresponses() as mocked: - # Mock API 响应 - mocked.get( - 'https://api.example.com/data', - payload={'result': 'success'}, - status=200 - ) - - result = await plugin.fetch_external_data() - - assert result['result'] == 'success' -``` - ---- - -## 测试最佳实践 - -### 1. 测试命名规范 - -```python -# 好的命名 -def test_calculate_sum_with_positive_numbers(): - """测试正数相加""" - pass - -def test_calculate_sum_with_negative_numbers(): - """测试负数相加""" - pass - -# 不好的命名 -def test1(): - pass - -def test_sum(): - pass -``` - -### 2. 一个测试一个概念 - -```python -# 好的做法:每个测试一个断言 -def test_user_creation(): - user = create_user("alice") - assert user.name == "alice" - -def test_user_creation_sets_default_role(): - user = create_user("alice") - assert user.role == "user" - -# 不好的做法:多个概念混在一起 -def test_user(): - user = create_user("alice") - assert user.name == "alice" - assert user.role == "user" - assert user.created_at is not None -``` - -### 3. 使用 Fixtures - -```python -# conftest.py -import pytest - -@pytest.fixture -def sample_user_data(): - """提供测试用户数据""" - return { - "user_id": "123", - "name": "Alice", - "email": "alice@example.com" - } - -@pytest.fixture -async def initialized_plugin(): - """提供已初始化的插件""" - plugin = MyPlugin() - harness = PluginTestHarness() - await plugin.on_start(harness.create_mock_ctx()) - yield plugin - await plugin.on_stop(None) - -# 测试中使用 -def test_with_fixture(sample_user_data, initialized_plugin): - result = initialized_plugin.process_user(sample_user_data) - assert result.success -``` - -### 4. 参数化测试 - -```python -import pytest - -@pytest.mark.parametrize("input,expected", [ - ("hello", "Hello"), - ("world", "World"), - ("", ""), -]) -def test_capitalize(input, expected): - assert input.capitalize() == expected - -@pytest.mark.asyncio -@pytest.mark.parametrize("command,expected_response", [ - ("/help", "可用命令..."), - ("/about", "关于信息..."), - ("/version", "版本号..."), -]) -async def test_commands(command, expected_response): - harness = PluginTestHarness() - plugin = await harness.load_plugin("my_plugin.main:MyPlugin") - - result = await harness.simulate_command(command) - assert expected_response in result.text -``` - -### 5. 测试隔离 - -```python -# 每个测试使用独立的数据 -@pytest.fixture(autouse=True) -def reset_state(): - """每个测试前重置状态""" - MyPlugin._instance_counter = 0 - yield - # 测试后清理 - MyPlugin._instance_counter = 0 - -@pytest.mark.asyncio -async def test_isolated(): - # 这个测试不会受其他测试影响 - plugin = MyPlugin() - assert plugin.id == 1 -``` - -### 6. 异步测试模式 - -```python -import asyncio -import pytest - -@pytest.mark.asyncio -async def test_async_operation(): - """测试异步操作""" - result = await async_function() - assert result == expected - -@pytest.mark.asyncio -async def test_async_timeout(): - """测试超时""" - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for( - slow_function(), - timeout=0.1 - ) - -@pytest.mark.asyncio -async def test_async_exception(): - """测试异常""" - with pytest.raises(ValueError) as exc_info: - await function_that_raises() - - assert "expected error" in str(exc_info.value) -``` - -### 7. 覆盖率检查 - -```bash -# 运行测试并生成覆盖率报告 -pytest --cov=my_plugin --cov-report=html - -# 检查覆盖率 -pytest --cov=my_plugin --cov-fail-under=80 -``` - -```ini -# .coveragerc -[run] -source = my_plugin -omit = - */tests/* - */venv/* - */__pycache__/* - -[report] -exclude_lines = - pragma: no cover - def __repr__ - raise NotImplementedError -``` - ---- - -## 测试工具函数 - -### 常用测试辅助函数 - -```python -# test_utils.py -import asyncio -from contextlib import asynccontextmanager - -async def run_with_timeout(coro, timeout=5): - """带超时运行协程""" - return await asyncio.wait_for(coro, timeout=timeout) - -@asynccontextmanager -async def temporary_database(): - """临时数据库上下文""" - db = await create_test_db() - try: - yield db - finally: - await db.cleanup() - -def create_test_event(**kwargs): - """创建测试事件""" - defaults = { - "text": "test", - "user_id": "12345", - "session_id": "test_session", - "platform": "qq", - } - defaults.update(kwargs) - return MockEvent(**defaults) -``` - ---- - -## 持续集成 - -### GitHub Actions 配置 - -```yaml -# .github/workflows/test.yml -name: Tests - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - pip install -r requirements.txt - pip install -r requirements-dev.txt - - - name: Run tests - run: | - pytest --cov=my_plugin --cov-report=xml - - - name: Upload coverage - uses: codecov/codecov-action@v3 -``` - ---- - -## 调试测试 - -### 使用 pdb - -```python -import pytest -import pdb - -def test_with_debug(): - result = some_function() - - # 设置断点 - pdb.set_trace() - - assert result.success -``` - -### 使用 pytest 的 --pdb - -```bash -# 失败时自动进入 pdb -pytest --pdb - -# 在第一个失败时停止 -pytest -x --pdb -``` - -### 详细输出 - -```bash -# 详细输出 -pytest -v - -# 最详细输出 -pytest -vv - -# 显示 print 输出 -pytest -s -``` - ---- - -## 相关文档 - -- [错误处理与调试](./06_error_handling.md) -- [高级主题](./07_advanced_topics.md) diff --git a/astrbot-sdk/src/astrbot_sdk/docs/09_api_reference.md b/astrbot-sdk/src/astrbot_sdk/docs/09_api_reference.md deleted file mode 100644 index 3aed1a7d6..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/09_api_reference.md +++ /dev/null @@ -1,34 +0,0 @@ -# AstrBot SDK 完整 API 参考 - -本文档提供 SDK 所有导出类和函数的完整参考,按模块分类。 - -## 相关文档 - -### 入门文档 -- [README](./README.md) -- [Context API 参考](./01_context_api.md) -- [消息事件与组件](./02_event_and_components.md) -- [装饰器使用指南](./03_decorators.md) - -### API 详细文档 -#### 核心类 -- [Star 类 API](./api/star.md) - 插件基类与生命周期 -- [Context 类 API](./api/context.md) - 运行时上下文与能力客户端 -- [MessageEvent 类 API](./api/message_event.md) - 消息事件对象 - -#### 装饰器与过滤器 -- [装饰器 API](./api/decorators.md) - 事件触发、限制器、过滤器装饰器 - -#### 客户端 -- [客户端 API](./api/clients.md) - LLM、Memory、DB、Platform 等 12 个客户端 - -#### 消息处理 -- [消息组件 API](./api/message_components.md) - Plain、Image、At、Record、Video、File 等 -- [消息结果 API](./api/message_result.md) - MessageChain、MessageBuilder、MessageEventResult - -#### 工具与类型 -- [工具与辅助类 API](./api/utils.md) - CancelToken、MessageSession、GreedyStr、CommandGroup 等 -- [类型定义 API](./api/types.md) - 类型别名、泛型变量、Pydantic 模型 - -#### 错误处理 -- [错误处理 API](./api/errors.md) - AstrBotError、ErrorCodes diff --git a/astrbot-sdk/src/astrbot_sdk/docs/10_migration_guide.md b/astrbot-sdk/src/astrbot_sdk/docs/10_migration_guide.md deleted file mode 100644 index 9da4360c4..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/10_migration_guide.md +++ /dev/null @@ -1,494 +0,0 @@ -# AstrBot SDK 迁移指南 - -本文档帮助开发者从旧版本或其他框架迁移到 AstrBot SDK v4。 - -## 目录 - -- [从 v3 迁移](#从-v3-迁移) -- [从其他框架迁移](#从其他框架迁移) -- [破坏性变更](#破坏性变更) -- [迁移检查清单](#迁移检查清单) - ---- - -## 从 v3 迁移 - -### 插件类定义 - -**v3 (旧版本)**: -```python -from astrbot.api import star - -@star.register("my_plugin") -class MyPlugin(star.Star): - def __init__(self, context): - super().__init__(context) -``` - -**v4 (新版本)**: -```python -from astrbot_sdk import Star - -class MyPlugin(Star): - async def on_start(self, ctx): - pass - - async def on_stop(self, ctx): - pass -``` - -### 装饰器变更 - -**v3**: -```python -from astrbot.api import filter - -@filter.command("hello") -async def hello(self, event): - await event.reply("Hello!") -``` - -**v4**: -```python -from astrbot_sdk.decorators import on_command - -@on_command("hello") -async def hello(self, event, ctx): - await event.reply("Hello!") -``` - -### Context 访问 - -**v3**: -```python -# 通过 self.context -config = self.context.get_config() -reply = await self.context.llm_generate("prompt") -``` - -**v4**: -```python -# 通过参数注入 -async def handler(self, event, ctx): - config = await ctx.metadata.get_plugin_config() - reply = await ctx.llm.chat("prompt") -``` - -### 数据存储 - -**v3**: -```python -# 通过 context -await self.context.put_kv_data("key", value) -data = await self.context.get_kv_data("key", default) -``` - -**v4**: -```python -# 通过 db 客户端 -await ctx.db.set("key", value) -data = await ctx.db.get("key") - -# 或使用 Mixin -from astrbot_sdk import PluginKVStoreMixin - -class MyPlugin(Star, PluginKVStoreMixin): - async def save(self): - await self.put_kv_data("key", value) -``` - -### 消息发送 - -**v3**: -```python -# 通过 event -await event.reply("消息") - -# 主动发送 -await self.context.send_message(session, chain) -``` - -**v4**: -```python -# 通过 event -await event.reply("消息") - -# 主动发送 -await ctx.platform.send(session, "消息") -await ctx.platform.send_chain(session, chain) -``` - -### 生命周期 - -**v3**: -```python -class MyPlugin(Star): - async def initialize(self): - # 初始化 - pass - - async def terminate(self): - # 清理 - pass -``` - -**v4**: -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 启动时 - await super().on_start(ctx) - - async def on_stop(self, ctx): - # 停止时 - await super().on_stop(ctx) - - # 仍然支持 - async def initialize(self): - pass - - async def terminate(self): - pass -``` - -### 配置获取 - -**v3**: -```python -config = self.context.get_config() -``` - -**v4**: -```python -config = await ctx.metadata.get_plugin_config() -``` - -### LLM 调用 - -**v3**: -```python -reply = await self.context.llm_generate("prompt") - -# 带历史 -reply = await self.context.llm_generate( - "prompt", - contexts=[{"role": "user", "content": "历史"}] -) -``` - -**v4**: -```python -from astrbot_sdk.clients.llm import ChatMessage - -reply = await ctx.llm.chat("prompt") - -# 带历史 -history = [ - ChatMessage(role="user", content="历史"), -] -reply = await ctx.llm.chat("prompt", history=history) -``` - -### 错误处理 - -**v3**: -```python -try: - result = await operation() -except Exception as e: - await event.reply(f"错误: {e}") -``` - -**v4**: -```python -from astrbot_sdk.errors import AstrBotError - -try: - result = await operation() -except AstrBotError as e: - # 使用 SDK 提供的用户友好提示 - await event.reply(e.hint or e.message) -except Exception as e: - ctx.logger.error(f"错误: {e}") - await event.reply("操作失败") -``` - ---- - -## 从其他框架迁移 - -### 从 NoneBot2 迁移 - -**NoneBot2**: -```python -from nonebot import on_command -from nonebot.adapters.onebot.v11 import Bot, Event - -matcher = on_command("hello") - -@matcher.handle() -async def hello(bot: Bot, event: Event): - await matcher.send("Hello!") -``` - -**AstrBot SDK**: -```python -from astrbot_sdk import Star, MessageEvent, Context -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") -``` - -### 从 Koishi 迁移 - -**Koishi**: -```javascript -ctx.command('hello') - .action(() => 'Hello!') -``` - -**AstrBot SDK**: -```python -from astrbot_sdk import Star, MessageEvent, Context -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") -``` - -### 从 python-telegram-bot 迁移 - -**python-telegram-bot**: -```python -from telegram import Update -from telegram.ext import ContextTypes - -async def hello(update: Update, context: ContextTypes.DEFAULT_TYPE): - await update.message.reply_text("Hello!") -``` - -**AstrBot SDK**: -```python -from astrbot_sdk import Star, MessageEvent, Context -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("hello") - @platforms("telegram") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello!") -``` - ---- - -## 破坏性变更 - -### v3 → v4 主要变更 - -1. **注册方式** - - v3: `@star.register()` + `@filter.command()` - - v4: `@on_command()` 直接在类方法上 - -2. **Context 获取** - - v3: `self.context` - - v4: `ctx` 参数注入 - -3. **数据存储** - - v3: `self.context.put_kv_data()` - - v4: `ctx.db.set()` 或 `PluginKVStoreMixin` - -4. **配置获取** - - v3: `self.context.get_config()` - - v4: `ctx.metadata.get_plugin_config()` - -5. **LLM 调用** - - v3: `self.context.llm_generate()` - - v4: `ctx.llm.chat()` - -6. **生命周期** - - v3: `initialize()` / `terminate()` - - v4: `on_start()` / `on_stop()`(仍然支持旧方法) - -7. **错误类型** - - v3: 标准 Python 异常 - - v4: `AstrBotError` 体系 - -### 已弃用的功能 - -| v3 功能 | v4 替代方案 | 状态 | -|---------|-------------|------| -| `@star.register()` | 继承 `Star` 类 | 已移除 | -| `self.context` | `ctx` 参数 | 已变更 | -| `filter.command()` | `on_command()` | 已更名 | -| `filter.regex()` | `on_message(regex=...)` | 已变更 | -| `llm_generate()` | `ctx.llm.chat()` | 已更名 | -| `send_message()` | `ctx.platform.send()` | 已更名 | - ---- - -## 迁移检查清单 - -### 代码迁移 - -- [ ] 更新导入语句 -- [ ] 移除 `@star.register()` 装饰器 -- [ ] 将 `@filter.command()` 改为 `@on_command()` -- [ ] 添加 `ctx` 参数到所有 handler -- [ ] 更新 Context 访问方式 -- [ ] 更新数据存储调用 -- [ ] 更新 LLM 调用 -- [ ] 更新配置获取 -- [ ] 更新错误处理 - -### 配置迁移 - -- [ ] 更新 `plugin.yaml` 格式 -- [ ] 检查 `support_platforms` 配置 -- [ ] 更新 `runtime` 配置 - -### 测试迁移 - -- [ ] 更新测试导入 -- [ ] 更新测试 mock -- [ ] 运行测试验证 - -### 文档更新 - -- [ ] 更新 README -- [ ] 更新使用文档 -- [ ] 更新 CHANGELOG - ---- - -## 迁移工具 - -### 自动迁移脚本(示例) - -```python -#!/usr/bin/env python3 -"""v3 到 v4 迁移辅助脚本""" - -import re -import sys -from pathlib import Path - -def migrate_file(file_path: Path): - """迁移单个文件""" - content = file_path.read_text(encoding="utf-8") - - # 替换导入 - content = re.sub( - r'from astrbot\.api import star', - 'from astrbot_sdk import Star, Context, MessageEvent', - content - ) - - # 替换装饰器 - content = re.sub( - r'@star\.register\([^)]*\)', - '', - content - ) - - content = re.sub( - r'@filter\.command\(([^)]*)\)', - r'@on_command(\1)', - content - ) - - # 替换类定义 - content = re.sub( - r'class (\w+)\(star\.Star\)', - r'class \1(Star)', - content - ) - - # 替换 context 访问 - content = re.sub( - r'self\.context\.get_config\(\)', - 'await ctx.metadata.get_plugin_config()', - content - ) - - content = re.sub( - r'self\.context\.llm_generate\(', - 'ctx.llm.chat(', - content - ) - - # 添加 ctx 参数 - content = re.sub( - r'async def (\w+)\(self, event\)', - r'async def \1(self, event, ctx)', - content - ) - - # 写回文件 - file_path.write_text(content, encoding="utf-8") - print(f"已迁移: {file_path}") - -def main(): - if len(sys.argv) < 2: - print("用法: python migrate.py ") - sys.exit(1) - - plugin_dir = Path(sys.argv[1]) - - for py_file in plugin_dir.rglob("*.py"): - migrate_file(py_file) - - print("迁移完成!请手动检查并测试。") - -if __name__ == "__main__": - main() -``` - ---- - -## 常见问题 - -### Q: v3 插件能在 v4 运行吗? - -**A**: 不能,需要进行迁移。但是 SDK 提供了兼容层,可以简化迁移过程。 - -### Q: 可以同时支持 v3 和 v4 吗? - -**A**: 不推荐。建议为 v4 创建新的插件版本。 - -### Q: 迁移后测试失败怎么办? - -**A**: -1. 检查导入是否正确 -2. 确认 `ctx` 参数已添加 -3. 验证异步函数使用 `await` -4. 查看错误日志获取详细信息 - -### Q: 如何逐步迁移? - -**A**: -1. 先迁移插件结构和装饰器 -2. 再迁移业务逻辑 -3. 最后更新测试 -4. 每个阶段都进行测试 - ---- - -## 获取帮助 - -- 查看完整文档:[docs/](./) -- 提交问题:[GitHub Issues](https://github.com/AstrBotDevs/AstrBot/issues) -- 迁移示例:[examples/migration/](./examples/migration/) - ---- - -## 相关文档 - -- [README](./README.md) -- [Context API 参考](./01_context_api.md) -- [Star 类与生命周期](./04_star_lifecycle.md) -- [错误处理与调试](./06_error_handling.md) diff --git a/astrbot-sdk/src/astrbot_sdk/docs/11_security_checklist.md b/astrbot-sdk/src/astrbot_sdk/docs/11_security_checklist.md deleted file mode 100644 index ac4ee5015..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/11_security_checklist.md +++ /dev/null @@ -1,382 +0,0 @@ -# AstrBot SDK 安全检查清单 - -本文档包含 SDK 安全开发检查清单和已知安全问题,帮助开发者编写安全的插件。 - -## 目录 - -- [安全检查清单](#安全检查清单) -- [已知安全问题](#已知安全问题) -- [安全最佳实践](#安全最佳实践) -- [安全审计指南](#安全审计指南) - ---- - -## 安全检查清单 - -### 输入验证 - -- [ ] 所有用户输入都经过验证 -- [ ] 输入长度有限制 -- [ ] 输入内容有白名单过滤 -- [ ] 特殊字符被正确转义 - -```python -# ✅ 好的做法 -import re -from astrbot_sdk.errors import AstrBotError - -def validate_input(text: str) -> str: - if len(text) > 1000: - raise AstrBotError.invalid_input("输入过长") - if not re.match(r'^[\w\s\-]+$', text): - raise AstrBotError.invalid_input("包含非法字符") - return text - -# ❌ 不好的做法 -async def unsafe_handler(event, ctx): - result = eval(event.text) # 危险! -``` - -### 敏感信息处理 - -- [ ] API Key 等敏感信息不硬编码 -- [ ] 敏感信息从配置或环境变量读取 -- [ ] 敏感信息不在日志中打印 -- [ ] 敏感信息不存储在不安全的位置 - -```python -# ✅ 好的做法 -import os - -class MyPlugin(Star): - async def on_start(self, ctx): - config = await ctx.metadata.get_plugin_config() - self.api_key = config.get("api_key") or os.getenv("MY_API_KEY") - ctx.logger.info("API Key 已配置") # 不打印实际值 - -# ❌ 不好的做法 -class UnsafePlugin(Star): - api_key = "sk-1234567890" # 硬编码! - - async def on_start(self, ctx): - ctx.logger.info(f"API Key: {self.api_key}") # 泄露! -``` - -### 权限检查 - -- [ ] 管理员命令有权限验证 -- [ ] 敏感操作有二次确认 -- [ ] 资源访问有权限控制 - -```python -# ✅ 好的做法 -from astrbot_sdk.decorators import require_admin - -class MyPlugin(Star): - @on_command("admin_only") - @require_admin - async def admin_cmd(self, event, ctx): - await event.reply("管理员命令") - -# ❌ 不好的做法 -class UnsafePlugin(Star): - @on_command("delete_all") - async def delete_all(self, event, ctx): - # 任何人都可以执行危险操作! - await ctx.db.clear_all() -``` - -### 速率限制 - -- [ ] 昂贵的操作有速率限制 -- [ ] API 调用有配额控制 -- [ ] 资源密集型操作有限制 - -```python -# ✅ 好的做法 -from astrbot_sdk.decorators import rate_limit - -class MyPlugin(Star): - @on_command("generate") - @rate_limit(limit=5, window=3600, scope="user") - async def generate(self, event, ctx): - # 昂贵的 LLM 调用 - result = await ctx.llm.chat("生成内容", model="gpt-4") - await event.reply(result) -``` - -### 资源管理 - -- [ ] 资源正确释放 -- [ ] 连接正确关闭 -- [ ] 任务正确取消 -- [ ] 避免资源泄漏 - -```python -# ✅ 好的做法 -class MyPlugin(Star): - async def on_start(self, ctx): - self._session = aiohttp.ClientSession() - self._task = asyncio.create_task(self.background_task()) - - async def on_stop(self, ctx): - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - if self._session: - await self._session.close() -``` - -### 错误处理 - -- [ ] 错误信息不泄露敏感信息 -- [ ] 异常被正确捕获和处理 -- [ ] 错误日志不包含敏感数据 - -```python -# ✅ 好的做法 -try: - result = await operation() -except Exception as e: - ctx.logger.error(f"操作失败: {type(e).__name__}") - await event.reply("操作失败,请稍后重试") - -# ❌ 不好的做法 -try: - result = await operation() -except Exception as e: - await event.reply(f"错误: {str(e)}") # 可能泄露敏感信息 -``` - ---- - -## 已知安全问题 - -当前版本没有已知的 SDK 框架级高风险未修复项。以下历史回归已经关闭, -保留在这里帮助开发者理解为什么这些约束存在: - -- `ProviderManagerClient.register_provider_change_hook()` 现在必须和 - `unregister_provider_change_hook()` 配对使用,避免残留订阅任务。 -- `PlatformCompatFacade` 内部已经串行化状态刷新,插件侧不需要再额外为 - `refresh()` / `clear_errors()` 套一层锁来规避 SDK 自身竞态。 -- Provider 管理路径会先复制 provider payload,再做 merge,避免污染共享缓存。 - ---- - -### 🟡 Medium: 命令参数注入风险 - -**问题描述**: -插件可能直接使用用户输入作为命令参数,存在注入风险。 - -**风险等级**: Medium - -**示例**: -```python -# ❌ 危险 -@on_command("search") -async def search(self, event, ctx, query): - # 如果 query 包含特殊字符,可能引发问题 - os.system(f"grep {query} data.txt") - -# ✅ 安全 -@on_command("search") -async def search(self, event, ctx, query): - # 验证和清理输入 - safe_query = re.sub(r'[^\w\s]', '', query) - subprocess.run(["grep", safe_query, "data.txt"], capture_output=True) -``` - ---- - -### 🟢 Low: 敏感信息可能出现在日志中 - -**问题描述**: -某些错误日志可能包含敏感信息。 - -**风险等级**: Low - -**建议**: -```python -# ✅ 安全的日志记录 -ctx.logger.info(f"用户 {user_id} 执行操作") # 只记录 ID - -# ❌ 不安全的日志记录 -ctx.logger.info(f"用户数据: {user_data}") # 可能包含敏感信息 -``` - ---- - -## 安全最佳实践 - -### 1. 最小权限原则 - -```python -class MyPlugin(Star): - @on_command("public") - async def public_cmd(self, event, ctx): - # 所有人可用 - pass - - @on_command("admin") - @require_admin - async def admin_cmd(self, event, ctx): - # 仅管理员可用 - pass - - @on_command("owner") - async def owner_cmd(self, event, ctx): - # 仅插件所有者可用 - if event.user_id != self.owner_id: - raise AstrBotError.invalid_input("权限不足") -``` - -### 2. 输入验证白名单 - -```python -import re - -ALLOWED_COMMANDS = {"help", "status", "info"} - -def validate_command(cmd: str) -> str: - cmd = cmd.lower().strip() - if cmd not in ALLOWED_COMMANDS: - raise AstrBotError.invalid_input("未知命令") - return cmd -``` - -### 3. 安全的文件操作 - -```python -import os -from pathlib import Path - -BASE_DIR = Path("/safe/directory") - -def safe_read_file(filename: str) -> str: - # 防止目录遍历 - path = (BASE_DIR / filename).resolve() - if not str(path).startswith(str(BASE_DIR)): - raise AstrBotError.invalid_input("非法路径") - - return path.read_text() -``` - -### 4. 安全的正则表达式 - -```python -import re - -# ✅ 使用原始字符串和适当的限制 -pattern = re.compile(r'^[a-zA-Z0-9_]{1,50}$') - -# ❌ 避免复杂的正则,可能导致 ReDoS -# pattern = re.compile(r'(a+)+b') # 危险! -``` - -### 5. 安全配置 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - config = await ctx.metadata.get_plugin_config() - - # 验证必需配置 - required = ["api_key", "endpoint"] - for key in required: - if key not in config: - raise ValueError(f"缺少必需配置: {key}") - - # 验证配置值 - if not config["api_key"].startswith("sk-"): - raise ValueError("无效的 API Key 格式") - - self.config = config -``` - ---- - -## 安全审计指南 - -### 审计检查清单 - -1. **代码审查** - - [ ] 所有输入都经过验证 - - [ ] 没有使用 eval/exec - - [ ] 没有硬编码的敏感信息 - - [ ] 错误处理不泄露敏感信息 - -2. **依赖审查** - ```bash - # 检查依赖漏洞 - pip install safety - safety check - - # 检查依赖许可证 - pip install pip-licenses - pip-licenses - ``` - -3. **日志审查** - - [ ] 日志不包含密码、token - - [ ] 日志不包含个人隐私信息 - - [ ] 日志有适当的级别 - -4. **权限审查** - - [ ] 敏感操作有权限检查 - - [ ] 没有特权提升漏洞 - - [ ] 资源访问有控制 - -### 安全测试 - -```python -# 测试输入验证 -def test_input_validation(): - # SQL 注入测试 - malicious_input = "' OR '1'='1" - - # XSS 测试 - xss_input = "" - - # 路径遍历测试 - path_input = "../../../etc/passwd" - - # 验证这些输入都被正确拒绝 -``` - -### 安全工具 - -```bash -# 静态分析 -pip install bandit -bandit -r my_plugin/ - -# 类型检查 -pip install mypy -mypy my_plugin/ - -# 代码质量 -pip install pylint -pylint my_plugin/ -``` - ---- - -## 报告安全问题 - -如果您发现 SDK 或插件的安全问题,请通过以下方式报告: - -1. **不要** 在公开 issue 中报告安全问题 -2. 通过项目官方联系渠道私下报告,例如 `community@astrbot.app` -3. 提供详细的复现步骤 -4. 等待修复后再公开 - ---- - -## 相关文档 - -- [错误处理与调试](./06_error_handling.md) -- [高级主题](./07_advanced_topics.md) -- [测试指南](./08_testing_guide.md) diff --git a/astrbot-sdk/src/astrbot_sdk/docs/INDEX.md b/astrbot-sdk/src/astrbot_sdk/docs/INDEX.md deleted file mode 100644 index 556f4db71..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/INDEX.md +++ /dev/null @@ -1,128 +0,0 @@ -# AstrBot SDK 文档目录 - -本文档目录包含完整的 SDK 开发文档,按难度级别分类。 - -## 📚 文档列表(按学习路径) - -### 🚀 快速开始(初级使用者) - -适合第一次接触 AstrBot SDK 的开发者: - -| 文档 | 描述 | 行数 | -|------|------|------| -| [README.md](./README.md) | 文档首页、快速开始、核心概念 | ~350 | -| [01_context_api.md](./01_context_api.md) | Context 类的核心客户端和系统工具方法 | ~650 | -| [02_event_and_components.md](./02_event_and_components.md) | MessageEvent 和消息组件的使用 | ~480 | -| [03_decorators.md](./03_decorators.md) | 所有装饰器的详细说明 | ~580 | -| [04_star_lifecycle.md](./04_star_lifecycle.md) | 插件基类和生命周期钩子 | ~490 | -| [05_clients.md](./05_clients.md) | 所有客户端的完整 API 文档 | ~422 | - -### 🔧 进阶主题(中级使用者) - -适合已经掌握基础,希望深入了解 SDK 的开发者: - -| 文档 | 描述 | 行数 | -|------|------|------| -| [06_error_handling.md](./06_error_handling.md) | 完整的错误处理指南和调试技巧 | ~530 | -| [07_advanced_topics.md](./07_advanced_topics.md) | 并发处理、性能优化、安全最佳实践 | ~550 | -| [08_testing_guide.md](./08_testing_guide.md) | 如何测试插件和 Mock 使用 | ~450 | - -### 📖 参考资料(高级使用者) - -适合需要深入了解 SDK 架构和完整 API 的开发者: - -| 文档 | 描述 | 行数 | -|------|------|------| -| [09_api_reference.md](./09_api_reference.md) | 所有导出类和函数的完整参考 | ~880 | -| [10_migration_guide.md](./10_migration_guide.md) | 从旧版本或其他框架迁移 | ~450 | -| [11_security_checklist.md](./11_security_checklist.md) | 安全开发检查清单和已知问题 | ~480 | -| [PROJECT_ARCHITECTURE.md](./PROJECT_ARCHITECTURE.md) | SDK 架构设计文档 | ~872 | - ---- - -## 📊 文档统计 - -- **总文档数**: 13 个 -- **总内容行数**: ~6,700 行 -- **新增/更新文档**: 7 个 -- **保留原有**: 6 个 -- **API 覆盖率**: 100% (77/77 exports documented) - ---- - -## 🎯 文档内容覆盖 - -### 已涵盖的主题 - -✅ **基础使用** -- Context API 完整参考 -- 消息事件处理 -- 消息组件使用 -- 装饰器使用 -- 生命周期管理 - -✅ **错误处理** -- AstrBotError 完整文档 -- 错误码参考 -- 错误处理模式 -- 调试技巧 - -✅ **高级主题** -- 并发处理 -- 性能优化 -- 安全最佳实践 -- 架构设计模式 - -✅ **测试** -- 单元测试 -- 集成测试 -- Mock 使用 -- 测试最佳实践 - -✅ **API 参考** -- 所有导出类的完整参考 -- 方法签名 -- 使用示例 - -✅ **迁移指南** -- v3 → v4 迁移 -- 从其他框架迁移 -- 破坏性变更列表 -- 迁移检查清单 - -✅ **安全检查清单** -- 安全开发检查清单 -- 已知安全问题(包含发现的问题) -- 安全最佳实践 -- 安全审计指南 - - -## 📝 文档使用建议 - -### 初级开发者 -1. 从 [README.md](./README.md) 开始 -2. 阅读 01-05 文档了解基础 API -3. 参考示例代码编写第一个插件 - -### 中级开发者 -1. 阅读 [06_error_handling.md](./06_error_handling.md) 建立健壮的错误处理 -2. 学习 [07_advanced_topics.md](./07_advanced_topics.md) 的并发和性能优化 -3. 按照 [08_testing_guide.md](./08_testing_guide.md) 编写测试 - -### 高级开发者 -1. 阅读 [09_api_reference.md](./09_api_reference.md) 了解所有可用功能 -2. 研究 [07_advanced_topics.md](./07_advanced_topics.md) 中的架构设计 -3. 阅读 [PROJECT_ARCHITECTURE.md](./PROJECT_ARCHITECTURE.md) 深入理解实现 - ---- - -## 🔗 相关资源 - -- **项目地址**: https://github.com/AstrBotDevs/AstrBot -- **SDK 版本**: v4.0 -- **协议版本**: P0.6 -- **Python 要求**: >= 3.12 - ---- - -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md b/astrbot-sdk/src/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md deleted file mode 100644 index e6c9b2f00..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md +++ /dev/null @@ -1,555 +0,0 @@ -# AstrBot SDK 架构概述文档 - -> 作者:whatevertogo -> 生成日期:2026-03-19 - ---- - -## 目录 - -1. [项目概述](#项目概述) -2. [核心架构层次](#核心架构层次) -3. [协议层设计](#协议层设计) -4. [运行时架构](#运行时架构) -5. [客户端层设计](#客户端层设计) -6. [插件开发指南](#插件开发指南) -7. [关键设计模式](#关键设计模式) -8. [文档与资源](#文档与资源) - ---- - -## 项目概述 - -AstrBot SDK 是一个基于 Python 3.12+ 的机器人插件开发框架,采用**进程隔离**和**能力路由**架构,支持插件的动态加载、独立运行和跨进程通信。 - -### 核心特性 - -| 特性 | 描述 | -|------|------| -| **进程隔离** | 每个插件运行在独立 Worker 进程,崩溃不影响其他插件 | -| **环境分组** | 多插件可共享同一 Python 虚拟环境,节省资源 | -| **能力路由** | 显式声明的 Capability 系统,支持 JSON Schema 验证 | -| **流式支持** | 原生支持流式 LLM 调用和增量结果返回 | -| **向后兼容** | 完整的旧版 API 兼容层,支持无修改迁移 | -| **协议优先** | 基于 v4 协议的统一通信模型,支持多种传输方式 | - -### 技术栈 - -- **Python**: 3.12+ -- **异步框架**: asyncio -- **Web 框架**: aiohttp -- **数据验证**: pydantic -- **日志**: loguru -- **配置**: pyyaml -- **LLM**: openai, anthropic, google-genai -- **包管理**: uv (环境分组) - ---- - -## 核心架构层次 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 用户层 (Plugin Developer) │ -├─────────────────────────────────────────────────────────────────┤ -│ v4 入口: astrbot_sdk.{Star, Context, MessageEvent} │ -│ 装饰器: on_command, on_message, on_event, on_schedule │ -│ provide_capability, require_admin │ -│ 过滤器: PlatformFilter, MessageTypeFilter, CustomFilter │ -│ 命令组: CommandGroup, command_group │ -│ 会话: MessageSession, session_waiter │ -└────────────────────┬────────────────────────────────────────────┘ - │ -┌──────────────────▼─────────────────────────────────────────────┐ -│ 高层 API (High-Level API) │ -├─────────────────────────────────────────────────────────────────┤ -│ 能力客户端 (通过 CapabilityProxy 调用): │ -│ - LLMClient (llm.chat, llm.chat_raw, llm.stream_chat)│ -│ - MemoryClient (memory.search, memory.save, memory.stats)│ -│ - DBClient (db.get, db.set, db.watch, db.list) │ -│ - PlatformClient (platform.send, platform.send_image, ...)│ -│ - HTTPClient (http.register_api, http.list_apis) │ -│ - MetadataClient (metadata.get_plugin, metadata.list_plugins)│ -└────────────────────┬────────────────────────────────────────────┘ - │ -┌──────────────────▼─────────────────────────────────────────────┐ -│ 执行边界 (Execution Boundary) │ -├─────────────────────────────────────────────────────────────────┤ -│ runtime 主干: │ -│ - loader.py (插件发现、加载、环境管理) │ -│ - bootstrap.py (Supervisor/Worker 启动) │ -│ - handler_dispatcher.py (Handler 执行分发、参数注入) │ -│ - capability_dispatcher.py (Capability 调用分发) │ -│ - capability_router.py (Capability 路由、Schema 验证) │ -│ - peer.py (协议对等端) │ -│ - transport.py (传输抽象) │ -└────────────────────┬────────────────────────────────────────────┘ - │ -┌──────────────────▼─────────────────────────────────────────────┐ -│ 协议与传输 (Protocol & Transport) │ -├─────────────────────────────────────────────────────────────────┤ -│ protocol/ │ -│ - messages.py (协议消息模型) │ -│ - descriptors.py (Handler/Capability 描述符) │ -│ transport 实现: │ -│ - StdioTransport (标准输入输出) │ -│ - WebSocketServerTransport (WebSocket 服务端) │ -│ - WebSocketClientTransport (WebSocket 客户端) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 层次职责 - -| 层次 | 职责 | 主要模块 | -|------|------|---------| -| **用户层** | 插件开发者 API | `Star`, `Context`, `MessageEvent`, 装饰器, 过滤器 | -| **高层 API** | 类型化的能力客户端 | `clients/{llm, memory, db, platform, http, metadata}` | -| **执行边界** | 插件加载、路由、分发 | `runtime/loader.py`, `runtime/*_dispatcher.py` | -| **协议层** | 消息模型、描述符、JSON Schema | `protocol/` | -| **传输层** | 底层通信抽象 | `runtime/transport.py` | - -### 核心设计原则 - -1. **延迟加载**:`runtime/__init__.py` 使用 `__getattr__` 避免导入时加载重型依赖 -2. **插件身份透传**:通过 `caller_plugin_scope()` 上下文管理器将 plugin_id 注入协议层 -3. **声明式优先**:所有配置都是数据结构(描述符),便于序列化和跨进程传递 -4. **类型安全**:使用 Pydantic 模型和类型注解提供验证和 IDE 支持 - ---- - -## 协议层设计 - -### 消息模型 - -v4 协议定义了 5 种消息类型: - -| 消息类型 | 用途 | 关键字段 | -|---------|------|---------| -| `InitializeMessage` | 握手初始化 | `protocol_version`, `peer`, `handlers`, `provided_capabilities` | -| `InvokeMessage` | 调用能力 | `capability`, `input`, `stream`, `caller_plugin_id` | -| `ResultMessage` | 返回结果 | `success`, `output`, `error`, `kind` | -| `EventMessage` | 流式事件 | `phase` (started/delta/completed/failed), `data` | -| `CancelMessage` | 取消调用 | `reason` | - -### 错误模型 - -`ErrorPayload` 使用字符串 code(而非整数),包含: -- `code`: 错误码(如 "capability_not_found") -- `message`: 开发者信息 -- `hint`: 用户友好提示 -- `retryable`: 是否可重试 - -### 握手流程 - -``` -Worker (Plugin) Supervisor (Core) - | | - | InitializeMessage | - | (handlers, capabilities) | - |----------------------------->| - | | - | ResultMessage(kind="init") | - |<-----------------------------| - | | - | InvokeMessage(handler.invoke) | - |<-----------------------------| - | 执行用户 handler | - | | - | ResultMessage(output) | - |----------------------------->| -``` - -### 描述符模型 - -#### HandlerDescriptor - -```python -{ - "id": "plugin.module:handler_name", - "trigger": { - "type": "command", - "command": "hello", - "aliases": ["hi"], - "description": "打招呼命令" - }, - "kind": "handler", # handler | hook | tool | session - "contract": "message_event", # message_event | schedule - "priority": 0, - "permissions": {"require_admin": False, "level": 0}, - "filters": [], - "param_specs": [] -} -``` - -#### Trigger 类型 - -| 类型 | 关键字段 | 说明 | -|------|---------|------| -| `CommandTrigger` | command, aliases, platforms | 命令触发 | -| `MessageTrigger` | regex, keywords, platforms | 消息触发(正则/关键词) | -| `EventTrigger` | event_type | 事件触发 | -| `ScheduleTrigger` | cron, interval_seconds | 定时触发 | - -### 内置 Capabilities (38个) - -#### LLM 命名空间 - -| 能力 | 说明 | -|------|------| -| `llm.chat` | 同步对话,返回文本 | -| `llm.chat_raw` | 同步对话,返回完整响应 | -| `llm.stream_chat` | 流式对话 | - -#### Memory 命名空间 - -| 能力 | 说明 | -|------|------| -| `memory.search` | 语义搜索记忆 | -| `memory.save` | 保存记忆 | -| `memory.save_with_ttl` | 保存带过期时间的记忆 | -| `memory.get` / `get_many` | 读取记忆 | -| `memory.delete` / `delete_many` | 删除记忆 | -| `memory.stats` | 获取统计信息 | - -#### DB 命名空间 - -| 能力 | 说明 | -|------|------| -| `db.get` / `get_many` | 读取 KV | -| `db.set` / `set_many` | 写入 KV | -| `db.delete` | 删除 KV | -| `db.list` | 列出键(支持前缀过滤) | -| `db.watch` | 订阅变更(流式) | - -#### Platform 命名空间 - -| 能力 | 说明 | -|------|------| -| `platform.send` | 发送文本消息 | -| `platform.send_image` | 发送图片 | -| `platform.send_chain` | 发送消息链 | -| `platform.get_members` | 获取群成员 | - -#### HTTP 命名空间 - -| 能力 | 说明 | -|------|------| -| `http.register_api` | 注册 HTTP API 端点 | -| `http.unregister_api` | 注销 HTTP API 端点 | -| `http.list_apis` | 列出已注册的 API | - -#### Metadata 命名空间 - -| 能力 | 说明 | -|------|------| -| `metadata.get_plugin` | 获取单个插件元数据 | -| `metadata.list_plugins` | 列出所有插件元数据 | -| `metadata.get_plugin_config` | 获取当前插件配置 | - -#### System 命名空间 - -| 能力 | 说明 | -|------|------| -| `system.get_data_dir` | 获取插件数据目录 | -| `system.text_to_image` | 文本转图片 | -| `system.html_render` | 渲染 HTML 模板 | -| `system.session_waiter.*` | 会话等待器管理 | -| `system.event.*` | 表情回应、输入状态、流式消息 | - ---- - -## 运行时架构 - -### 组件关系图 - -``` - ┌──────────────┐ - │ AstrBot │ - │ Core │ - └──────┬─────┘ - │ - ┌──────▼─────┐ - │ Supervisor │ - │ Runtime │ - └──────┬─────┘ - │ - ┌──────────────────┼──────────────────┐ - │ │ │ - ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ - │ Peer │ │ Peer │ │ Peer │ - │ (stdio) │ │ (stdio) │ │ (stdio) │ - └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ - │ │ │ - ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ - │ Worker │ │ Worker │ │ Worker │ - │ Runtime │ │ Runtime │ │ Runtime │ - └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ - │ │ │ - ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ - │ Plugin A │ │ Plugin B │ │ Plugin C │ - └───────────┘ └───────────┘ └───────────┘ -``` - -### 核心运行时组件 - -| 组件 | 职责 | -|------|------| -| **SupervisorRuntime** | 管理多个 Worker 进程,聚合所有 handler | -| **WorkerSession** | 管理单个 Worker 进程的生命周期 | -| **PluginWorkerRuntime** | Worker 进程内的插件加载与执行 | -| **HandlerDispatcher** | 将 handler.invoke 请求转成真实 Python 调用 | -| **CapabilityRouter** | 能力注册、发现和执行路由 | - -### 参数注入优先级 - -HandlerDispatcher 支持参数注入,优先级为: - -1. **按类型注解注入**(`MessageEvent`, `Context`) -2. **按参数名注入**(`event`, `ctx`, `context`) -3. **从 legacy_args 注入**(命令参数等) - ---- - -## 客户端层设计 - -### 客户端架构 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ User Plugin │ -│ ctx.llm.chat() / ctx.memory.save() / ctx.db.set() │ -└────────────┬──────────────────────────────────────────────┘ - │ -┌────────────▼──────────────────────────────────────────────┐ -│ CapabilityProxy │ -│ - call(name, payload) 普通调用 │ -│ - stream(name, payload) 流式调用 │ -└────────────┬──────────────────────────────────────────────┘ - │ -┌────────────▼──────────────────────────────────────────────┐ -│ Peer │ -│ - invoke(capability, payload) │ -│ - invoke_stream(capability, payload) │ -└────────────┬──────────────────────────────────────────────┘ - │ -┌────────────▼──────────────────────────────────────────────┐ -│ Transport │ -│ - send(json_string) │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 客户端一览 - -| 客户端 | 主要方法 | 对应 Capability | -|--------|---------|-----------------| -| `LLMClient` | `chat()`, `chat_raw()`, `stream_chat()` | `llm.*` | -| `MemoryClient` | `search()`, `save()`, `save_with_ttl()`, `get()`, `get_many()`, `delete()`, `delete_many()`, `stats()` | `memory.*` | -| `DBClient` | `get()`, `set()`, `get_many()`, `set_many()`, `delete()`, `list()`, `watch()` | `db.*` | -| `PlatformClient` | `send()`, `send_image()`, `send_chain()`, `get_members()` | `platform.*` | -| `HTTPClient` | `register_api()`, `unregister_api()`, `list_apis()` | `http.*` | -| `MetadataClient` | `get_plugin()`, `list_plugins()`, `get_current_plugin()`, `get_plugin_config()` | `metadata.*` | - ---- - -## 插件开发指南 - -### v4 原生插件示例 - -#### plugin.yaml - -```yaml -_schema_version: 2 -name: my_plugin -author: your_name -version: 1.0.0 -runtime: - python: "3.12" -components: - - class: main:MyPlugin -``` - -#### main.py - -```python -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command, on_message, provide_capability - -class MyPlugin(Star): - # 命令处理器 - @on_command("hello", aliases=["hi"]) - async def hello(self, event: MessageEvent, ctx: Context) -> None: - await event.reply(f"你好,{event.user_id}!") - - # 消息处理器 - @on_message(keywords=["帮助"]) - async def help(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("可用命令:hello, help") - - # 提供能力 - @provide_capability( - "my_plugin.calculate", - description="执行计算", - input_schema={ - "type": "object", - "properties": {"x": {"type": "number"}}, - "required": ["x"] - }, - output_schema={ - "type": "object", - "properties": {"result": {"type": "number"}}, - "required": ["result"] - } - ) - async def calculate_capability(self, payload: dict, ctx: Context) -> dict: - x = payload.get("x", 0) - return {"result": x * 2} -``` - -### 生命周期钩子 - -| 钩子 | 说明 | -|------|------| -| `on_start()` | 插件启动时调用 | -| `on_stop()` | 插件停止时调用 | -| `on_error(exc, event, ctx)` | Handler 执行出错时调用 | - -### 常用功能速查 - -#### 1. LLM 对话 - -```python -# 简单对话 -reply = await ctx.llm.chat("你好") - -# 带历史对话 -from astrbot_sdk.clients.llm import ChatMessage -history = [ChatMessage(role="user", content="我叫小明")] -reply = await ctx.llm.chat("你记得我吗?", history=history) - -# 流式对话 -async for chunk in ctx.llm.stream_chat("讲个故事"): - print(chunk, end="") -``` - -#### 2. 数据持久化 - -```python -# DB 客户端(精确匹配) -await ctx.db.set("user:123", {"name": "Alice"}) -data = await ctx.db.get("user:123") - -# Memory 客户端(语义搜索) -await ctx.memory.save("user_pref", {"theme": "dark"}) -results = await ctx.memory.search("用户喜欢什么颜色") -``` - -#### 3. 消息发送 - -```python -# 简单文本 -await ctx.platform.send(event.session_id, "消息内容") - -# 图片 -await ctx.platform.send_image(event.session_id, "https://example.com/img.jpg") - -# 消息链 -from astrbot_sdk.message_components import Plain, Image -chain = [Plain("文字"), Image(url="https://example.com/img.jpg")] -await ctx.platform.send_chain(event.session_id, chain) -``` - ---- - -## 关键设计模式 - -### 1. 协议优先模式 - -- 所有跨进程通信都通过 v4 协议 -- 传输层只处理字符串,协议由 Peer 层处理 -- 支持多种传输方式(Stdio, WebSocket) - -### 2. 能力路由模式 - -- 显式声明 Capability 和输入/输出 Schema -- 通过 CapabilityRouter 统一路由 -- 支持同步和流式两种调用模式 -- 冲突处理:保留命名空间冲突直接跳过,非保留命名空间冲突自动添加插件名前缀 - -### 3. 环境分组模式 - -- 多插件可共享同一 Python 虚拟环境 -- 按版本和依赖兼容性自动分组 -- 节省资源,加快启动速度 - -### 4. 参数注入模式 - -- HandlerDispatcher 支持类型注解注入 -- 优先级:类型注解 > 参数名 > legacy_args -- 支持可选类型 `Optional[Type]` - -### 5. 取消传播模式 - -- CancelToken 统一取消机制 -- 跨进程取消通过 CancelMessage -- 早到取消避免竞态条件 - -### 6. 插件隔离模式 - -- 每个插件运行在独立 Worker 进程 -- 崩溃不影响其他插件 -- 支持 GroupWorkerRuntime 共享环境 - -### 7. 热重载模式 - -- `dev --watch` 支持文件变更检测 -- 按插件目录清理 `sys.modules` 缓存 -- 确保代码变更后正确重载 - ---- - -## 文档与资源 - -### 完整文档目录 - -SDK 文档按学习路径组织,位于 `src/astrbot_sdk/docs/`: - -| 级别 | 文档 | 内容 | -|------|------|------| -| **初级** | README.md | 快速开始、核心概念 | -| | 01_context_api.md | Context API 完整参考 | -| | 02_event_and_components.md | MessageEvent 和消息组件 | -| | 03_decorators.md | 装饰器详细说明 | -| | 04_star_lifecycle.md | 插件基类和生命周期 | -| | 05_clients.md | 客户端 API 文档 | -| **中级** | 06_error_handling.md | 错误处理与调试 | -| | 07_advanced_topics.md | 并发、性能优化、安全 | -| | 08_testing_guide.md | 测试指南 | -| **高级** | 09_api_reference.md | 完整 API 索引 | -| | 10_migration_guide.md | 迁移指南 | -| | 11_security_checklist.md | 安全检查清单 | -| | PROJECT_ARCHITECTURE.md | 架构设计文档 | - -### 关键文件速查 - -| 文件 | 核心类/函数 | 说明 | -|------|------------|------| -| `astrbot_sdk/__init__.py` | `Star`, `Context`, `MessageEvent` | 顶层入口 | -| `astrbot_sdk/star.py` | `Star` | v4 原生插件基类 | -| `astrbot_sdk/context.py` | `Context` | 运行时上下文 | -| `astrbot_sdk/decorators.py` | `on_command`, `on_message` | v4 装饰器 | -| `astrbot_sdk/errors.py` | `AstrBotError` | 统一错误模型 | -| `astrbot_sdk/runtime/peer.py` | `Peer` | 协议对等端 | -| `astrbot_sdk/runtime/capability_router.py` | `CapabilityRouter` | Capability 路由 | -| `astrbot_sdk/clients/llm.py` | `LLMClient` | LLM 客户端 | - -### 版本信息 - -- **SDK 版本**: v4.0 -- **协议版本**: P0.6 -- **Python 要求**: >=3.12 -- **推荐版本**: 3.12+ - ---- - -> 本文档基于 AstrBot SDK v4 架构文档整理 -> 详细内容请查阅 `src/astrbot_sdk/docs/` 目录下的完整文档 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/README.md b/astrbot-sdk/src/astrbot_sdk/docs/README.md deleted file mode 100644 index 852ff9080..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/README.md +++ /dev/null @@ -1,445 +0,0 @@ -# AstrBot SDK 插件开发文档 - -欢迎来到 AstrBot SDK 插件开发文档!本文档面向 SDK 插件开发者,提供从入门到精通的完整指南。 - -## 📚 文档目录 - -### 🚀 快速开始(初级使用者) - -适合第一次接触 AstrBot SDK 的开发者: - -- **[01. Context API 参考](./01_context_api.md)** - Context 类的核心客户端和系统工具方法 -- **[02. 消息事件与组件](./02_event_and_components.md)** - MessageEvent 和消息组件的使用 -- **[03. 装饰器使用指南](./03_decorators.md)** - 所有装饰器的详细说明 -- **[04. Star 类与生命周期](./04_star_lifecycle.md)** - 插件基类和生命周期钩子 -- **[05. 客户端 API 参考](./05_clients.md)** - 所有客户端的完整 API 文档 - -### 🔧 进阶主题(中级使用者) - -适合已经掌握基础,希望深入了解 SDK 的开发者: - -- **[06. 错误处理与调试](./06_error_handling.md)** - 完整的错误处理指南和调试技巧 -- **[07. 高级主题](./07_advanced_topics.md)** - 并发处理、性能优化、安全最佳实践 -- **[08. 测试指南](./08_testing_guide.md)** - 如何测试插件和 Mock 使用 - -### 📖 参考资料(高级使用者) - -适合需要深入了解 SDK 架构和完整 API 的开发者: - -- **[09. 完整 API 索引](./09_api_reference.md)** - 所有导出类和函数的完整参考 -- **[10. 迁移指南](./10_migration_guide.md)** - 从旧版本或其他框架迁移 -- **[11. 安全检查清单](./11_security_checklist.md)** - 安全开发检查清单和已知问题 - ---- - -## 🎯 学习路径推荐 - -### 初级路径:快速上手 - -``` -1. 阅读本 README 的快速开始部分 -2. 跟随下面的"创建第一个插件"教程 -3. 查阅 01-05 文档了解基础 API -4. 参考文档中的示例代码 -``` - -### 中级路径:进阶开发 - -``` -1. 阅读 06 错误处理指南,建立健壮的错误处理机制 -2. 学习 07 高级主题中的并发和性能优化 -3. 按照 08 测试指南编写测试 -4. 尝试开发复杂的插件功能 -``` - -### 高级路径:精通 SDK - -``` -1. 阅读 09 完整 API 索引,了解所有可用功能 -2. 研究 07 高级主题中的架构设计 -3. 阅读 SDK 源码深入理解实现 -4. 参与 SDK 贡献和改进 -``` - ---- - -## 🚀 快速上手 - -### 创建第一个插件 - -```python -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command, on_message - -class MyPlugin(Star): - """我的第一个插件""" - - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - """打招呼命令""" - await event.reply(f"你好,{event.sender_name}!") - - @on_message(keywords=["帮助", "help"]) - async def help(self, event: MessageEvent, ctx: Context): - """帮助信息""" - await event.reply("可用命令: /hello") -``` - -### 插件配置 (plugin.yaml) - -```yaml -_schema_version: 2 -name: my_plugin -author: your_name -version: 1.0.0 -desc: 我的插件描述 - -runtime: - python: "3.12" - -components: - - class: main:MyPlugin - -support_platforms: - - aiocqhttp - - telegram -``` - ---- - -## 📖 核心概念 - -### Context - 能力访问入口 - -`Context` 是插件与 AstrBot Core 交互的主要入口: - -```python -# LLM 对话 -reply = await ctx.llm.chat("你好") - -# 数据存储 -await ctx.db.set("key", "value") -data = await ctx.db.get("key") - -# 记忆存储 -await ctx.memory.save("pref", {"theme": "dark"}) - -# 发送消息 -await ctx.platform.send(event.session_id, "消息内容") - -# 获取配置 -config = await ctx.metadata.get_plugin_config() -``` - -### MessageEvent - 消息事件 - -`MessageEvent` 表示接收到的消息事件: - -```python -# 回复消息 -await event.reply("回复内容") - -# 获取消息组件 -images = event.get_images() - -# 判断消息类型 -if event.is_group_chat(): - await event.reply("这是群聊消息") - -# 构建返回结果 -return event.plain_result("返回内容") -``` - -### 装饰器 - 事件处理注册 - -```python -from astrbot_sdk.decorators import ( - on_command, # 命令触发 - on_message, # 消息触发 - on_event, # 事件触发 - on_schedule, # 定时任务 - require_admin, # 权限控制 - rate_limit, # 速率限制 -) - -@on_command("test") -@rate_limit(5, 60) -async def test_handler(self, event: MessageEvent, ctx: Context): - await event.reply("测试") -``` - ---- - -## 🔧 常用功能速查 - -### 1. LLM 对话 - -```python -# 简单对话 -reply = await ctx.llm.chat("你好") - -# 带历史对话 -from astrbot_sdk.clients.llm import ChatMessage - -history = [ - ChatMessage(role="user", content="我叫小明"), - ChatMessage(role="assistant", content="你好小明!"), -] -reply = await ctx.llm.chat("你记得我吗?", history=history) - -# 流式对话 -async for chunk in ctx.llm.stream_chat("讲个故事"): - print(chunk, end="") -``` - -### 2. 数据持久化 - -```python -# DB 客户端(精确匹配) -await ctx.db.set("user:123", {"name": "Alice"}) -data = await ctx.db.get("user:123") - -# Memory 客户端(语义搜索) -await ctx.memory.save("user_pref", {"theme": "dark"}) -results = await ctx.memory.search("用户喜欢什么颜色") -``` - -### 3. 消息发送 - -```python -# 简单文本 -await ctx.platform.send(event.session_id, "消息内容") - -# 图片 -await ctx.platform.send_image(event.session_id, "https://example.com/img.jpg") - -# 消息链 -from astrbot_sdk.message_components import Plain, Image - -chain = [Plain("文字"), Image(url="https://example.com/img.jpg")] -await ctx.platform.send_chain(event.session_id, chain) -``` - -### 4. 文件处理 - -```python -from astrbot_sdk.message_components import Image - -# 注册文件到文件服务 -img = Image.fromFileSystem("/path/to/image.jpg") -public_url = await img.register_to_file_service() -``` - ---- - -## 🛠️ 高级功能 - -### 1. LLM 工具注册 - -```python -async def search_weather(location: str) -> str: - return f"{location} 今天晴天" - -await ctx.register_llm_tool( - name="search_weather", - parameters_schema={ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名称"} - }, - "required": ["location"] - }, - desc="搜索天气信息", - func_obj=search_weather -) -``` - -### 2. Web API 注册 - -```python -from astrbot_sdk.decorators import provide_capability - -@provide_capability( - name="my_plugin.api", - description="处理 HTTP 请求" -) -async def handle_api(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - -await ctx.http.register_api( - route="/my-api", - handler=handle_api, - methods=["GET", "POST"] -) -``` - -### 3. 后台任务 - -```python -async def background_work(): - while True: - await asyncio.sleep(60) - ctx.logger.info("每分钟执行一次") - -task = await ctx.register_task(background_work(), "定时任务") -``` - ---- - -## 📋 最佳实践 - -### 1. 错误处理 - -```python -from astrbot_sdk.errors import AstrBotError - -@on_command("risky") -async def risky_handler(self, event: MessageEvent, ctx: Context): - try: - result = await risky_operation() - await event.reply(f"成功: {result}") - except AstrBotError as e: - # SDK 错误包含用户友好的提示 - await event.reply(e.hint or e.message) - except ValueError as e: - await event.reply(f"参数错误: {e}") - except Exception as e: - ctx.logger.error(f"操作失败: {e}", exc_info=e) - raise -``` - -### 2. 日志记录 - -```python -# 不同级别的日志 -ctx.logger.debug("调试信息") -ctx.logger.info("普通信息") -ctx.logger.warning("警告信息") -ctx.logger.error("错误信息") - -# 绑定上下文 -logger = ctx.logger.bind(user_id=event.user_id) -logger.info("用户操作") -``` - -### 3. 配置管理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - config = await ctx.metadata.get_plugin_config() - - # 提供默认值 - self.timeout = config.get("timeout", 30) - - # 验证必需配置 - if "api_key" not in config: - raise ValueError("缺少必需配置: api_key") - - self.api_key = config["api_key"] -``` - -### 4. 资源清理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - self._session = aiohttp.ClientSession() - self._task = asyncio.create_task(self.background_task()) - - async def on_stop(self, ctx): - if hasattr(self, '_task'): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - if hasattr(self, '_session'): - await self._session.close() -``` - ---- - -## 🔍 注意事项 - -1. **异步操作**:所有客户端方法都是异步的,需要使用 `await` - -2. **插件隔离**:每个插件有独立的 Context 实例 - -3. **错误处理**:所有远程调用都可能失败,建议使用 try-except - -4. **Memory vs DB**: - - Memory: 语义搜索,适合 AI 上下文 - - DB: 精确匹配,适合结构化数据 - -5. **平台标识**:使用 UMO 格式 `"platform:instance:session_id"` - -6. **装饰器顺序**:事件触发 → 过滤器 → 限制器 → 修饰器 - -7. **安全提示**: - - 不要在插件中存储敏感信息(API Key 等应使用配置) - - 验证所有用户输入 - - 注意资源泄漏(任务、连接等需要正确清理) - - 遵循最小权限原则 - ---- - -## 🐛 调试技巧 - -### 启用调试日志 - -```python -# 在插件中获取 logger -logger = ctx.logger - -# 记录详细信息 -logger.debug(f"收到消息: {event.text}") -logger.debug(f"用户ID: {event.user_id}") -``` - -### 使用测试框架 - -```python -from astrbot_sdk.testing import PluginTestHarness - -async def test_my_plugin(): - harness = PluginTestHarness() - plugin = harness.load_plugin("my_plugin.main:MyPlugin") - - # 模拟事件 - result = await harness.simulate_command("/hello") - assert result.text == "Hello!" -``` - ---- - -## 📞 获取帮助 - -- **查看详细文档**:[docs/](./) -- **完整 API 索引**:[09_api_reference.md](./09_api_reference.md) -- **错误处理指南**:[06_error_handling.md](./06_error_handling.md) -- **安全检查清单**:[11_security_checklist.md](./11_security_checklist.md) -- **提交问题**:[GitHub Issues](https://github.com/AstrBotDevs/astrbot-sdk/issues) -- **参与讨论**:[GitHub Discussions](https://github.com/AstrBotDevs/astrbot-sdk/discussions) - ---- - -## 📚 版本信息 - -- **SDK 版本**: v4.0 -- **最后更新**: 2026-03-17 -- **Python 要求**: >= 3.12 -- **协议版本**: P0.6 - ---- - -## 📝 文档贡献 - -如果您发现文档中的错误或想改进文档,欢迎提交 PR! - -**文档规范**: -- 使用清晰的代码示例 -- 包含错误处理示例 -- 标注 API 的稳定性和版本要求 -- 提供初级和高级两种使用方式 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/clients.md b/astrbot-sdk/src/astrbot_sdk/docs/api/clients.md deleted file mode 100644 index 2a3151176..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/clients.md +++ /dev/null @@ -1,1282 +0,0 @@ -# 客户端 API 完整参考 - -## 概述 - -本文档详细介绍 `astrbot_sdk/clients/` 目录下所有客户端的 API。客户端是 Context 中暴露的各种能力接口,每个客户端负责一类特定的功能。 - -**模块路径**: `astrbot_sdk.clients` - ---- - -## 目录 - -- [LLMClient - AI 对话客户端](#llmclient---ai-对话客户端) -- [MemoryClient - 记忆存储客户端](#memoryclient---记忆存储客户端) -- [DBClient - KV 数据库客户端](#dbclient---kv-数据库客户端) -- [PlatformClient - 平台消息客户端](#platformclient---平台消息客户端) -- [FileServiceClient - 文件服务客户端](#fileserviceclient---文件服务客户端) -- [HTTPClient - HTTP API 客户端](#httpclient---http-api-客户端) -- [MetadataClient - 插件元数据客户端](#metadataclient---插件元数据客户端) -- [ProviderClient - Provider 发现客户端](#providerclient---provider-发现客户端) -- [ProviderManagerClient - Provider 管理客户端](#providermanagerclient---provider-管理客户端) -- [PersonaManagerClient - 人格管理客户端](#personamanagerclient---人格管理客户端) -- [ConversationManagerClient - 对话管理客户端](#conversationmanagerclient---对话管理客户端) -- [KnowledgeBaseManagerClient - 知识库管理客户端](#knowledgebasemanagerclient---知识库管理客户端) - ---- - -## LLMClient - AI 对话客户端 - -提供与大语言模型交互的能力,支持普通聊天、流式聊天和结构化响应。 - -### 导入 - -```python -from astrbot_sdk.clients import LLMClient, ChatMessage, LLMResponse -``` - -### 方法 - -#### `chat(prompt, *, system, history, contexts, provider_id, model, temperature, **kwargs)` - -发送聊天请求并返回文本响应。 - -**参数**: -- `prompt` (`str`): 用户输入的提示文本 -- `system` (`str | None`): 系统提示词 -- `history` / `contexts` (`Sequence[ChatHistoryItem] | None`): 对话历史 -- `provider_id` (`str | None`): 指定使用的 provider -- `model` (`str | None`): 指定模型名称 -- `temperature` (`float | None`): 生成温度(0-1) -- `**kwargs`: 额外透传参数(如 `image_urls`, `tools`) - -**返回**: `str` - 生成的文本内容 - -**示例**: - -```python -# 简单对话 -reply = await ctx.llm.chat("你好,介绍一下自己") - -# 带系统提示词 -reply = await ctx.llm.chat( - "翻译成英文", - system="你是一个专业翻译助手" -) - -# 带对话历史 -history = [ - ChatMessage(role="user", content="我叫小明"), - ChatMessage(role="assistant", content="你好小明!"), -] -reply = await ctx.llm.chat("你记得我吗?", history=history) - -# 使用字典格式的对话历史 -history = [ - {"role": "user", "content": "我叫小明"}, - {"role": "assistant", "content": "你好小明!"}, -] -reply = await ctx.llm.chat("你记得我吗?", history=history) -``` - ---- - -#### `chat_raw(prompt, *, system, history, contexts, provider_id, model, temperature, **kwargs)` - -发送聊天请求并返回完整响应对象。 - -**返回**: `LLMResponse` 对象,包含: -- `text`: 生成的文本内容 -- `usage`: Token 使用统计 -- `finish_reason`: 结束原因 -- `tool_calls`: 工具调用列表 -- `role`: 响应角色 - -**示例**: - -```python -response = await ctx.llm.chat_raw("写一首诗", temperature=0.8) -print(f"生成文本: {response.text}") -print(f"Token 使用: {response.usage}") -print(f"结束原因: {response.finish_reason}") - -# 处理工具调用 -if response.tool_calls: - for tool_call in response.tool_calls: - print(f"工具调用: {tool_call}") -``` - ---- - -#### `stream_chat(prompt, *, system, history, contexts, provider_id, model, temperature, **kwargs)` - -流式聊天,逐块返回响应文本。 - -**返回**: 异步生成器,逐块生成文本 - -**示例**: - -```python -# 实时显示生成内容 -async for chunk in ctx.llm.stream_chat("讲一个故事"): - print(chunk, end="", flush=True) - -# 收集完整响应 -full_text = "" -async for chunk in ctx.llm.stream_chat("写一篇文章"): - full_text += chunk - # 实时处理每个 chunk -``` - ---- - -## MemoryClient - 记忆存储客户端 - -提供 AI 记忆的存储和检索能力,支持语义搜索。与 DBClient 不同,MemoryClient 使用向量相似度进行语义匹配。 - -### 导入 - -```python -from astrbot_sdk.clients import MemoryClient -``` - -### 方法 - -#### `search(query, *, mode="auto", limit=None, min_score=None, provider_id=None)` - -搜索记忆项。默认会在存在 embedding provider 时执行 hybrid 检索, -否则退化为关键词检索。 - -**参数**: -- `query` (`str`): 搜索查询文本(自然语言) -- `mode` (`Literal["auto", "keyword", "vector", "hybrid"]`): 搜索模式 -- `limit` (`int | None`): 最大返回条数 -- `min_score` (`float | None`): 最低分数阈值 -- `provider_id` (`str | None`): 指定 embedding provider - -**返回**: `list[dict]` - 匹配的记忆项列表。每项至少包含 `key`、`value`、`score`、`match_type` - -**示例**: - -```python -# 搜索用户偏好 -results = await ctx.memory.search("用户喜欢什么颜色", mode="hybrid", limit=5) -for item in results: - print(item["key"], item["score"], item["match_type"]) - -# 强制使用关键词检索 -keyword_hits = await ctx.memory.search("blue", mode="keyword", min_score=0.9) - -# 使用当前激活的 embedding provider 执行向量检索 -vector_hits = await ctx.memory.search("之前讨论过什么技术话题", mode="vector") -``` - ---- - -#### `save(key, value, **extra)` - -保存记忆项。 - -**参数**: -- `key` (`str`): 记忆项的唯一标识键 -- `value` (`dict | None`): 要存储的数据字典 -- `**extra`: 额外的键值对,会合并到 value 中 - -**示例**: - -```python -# 保存用户偏好 -await ctx.memory.save("user_pref", { - "theme": "dark", - "lang": "zh", - "favorite_color": "blue" -}) - -# 使用关键字参数 -await ctx.memory.save( - "note", - None, - content="重要笔记", - tags=["work"], - timestamp="2024-01-01" -) - -# 显式指定检索文本 -await ctx.memory.save( - "profile:alice", - { - "name": "Alice", - "city": "Shanghai", - "embedding_text": "Alice 喜欢蓝色、海边和摄影", - }, -) -``` - ---- - -#### `get(key)` - -精确获取单个记忆项。 - -**参数**: -- `key` (`str`): 记忆项的唯一键 - -**返回**: `dict | None` - 记忆项内容字典,不存在则返回 None - -**示例**: - -```python -pref = await ctx.memory.get("user_pref") -if pref: - print(f"用户偏好主题: {pref.get('theme')}") -``` - ---- - -#### `delete(key)` - -删除记忆项。 - -**参数**: -- `key` (`str`): 要删除的记忆项键名 - -**示例**: - -```python -await ctx.memory.delete("old_note") -``` - ---- - -#### `save_with_ttl(key, value, ttl_seconds)` - -保存带过期时间的记忆项。 - -**参数**: -- `key` (`str`): 记忆项的唯一标识键 -- `value` (`dict`): 要存储的数据字典 -- `ttl_seconds` (`int`): 存活时间(秒),必须大于 0 - -**异常**: -- `TypeError`: value 不是 dict 类型 -- `ValueError`: ttl_seconds 小于 1 - -**示例**: - -```python -# 保存临时会话状态,1小时后过期 -await ctx.memory.save_with_ttl( - "session_temp", - {"state": "waiting", "step": 1}, - ttl_seconds=3600 -) - -# 保存验证码,5分钟后过期 -await ctx.memory.save_with_ttl( - "verification_code", - {"code": "123456", "user_id": "user123"}, - ttl_seconds=300 -) -``` - ---- - -#### `get_many(keys)` - -批量获取多个记忆项。 - -**参数**: -- `keys` (`list[str]`): 记忆项键名列表 - -**返回**: `list[dict]` - 记忆项列表 - -**示例**: - -```python -items = await ctx.memory.get_many(["pref1", "pref2", "pref3"]) -for item in items: - if item["value"]: - print(f"{item['key']}: {item['value']}") -``` - ---- - -#### `delete_many(keys)` - -批量删除多个记忆项。 - -**参数**: -- `keys` (`list[str]`): 要删除的记忆项键名列表 - -**返回**: `int` - 实际删除的记忆项数量 - -**示例**: - -```python -deleted = await ctx.memory.delete_many(["old1", "old2", "old3"]) -print(f"删除了 {deleted} 条记忆") -``` - ---- - -#### `stats()` - -获取记忆系统统计信息。 - -**返回**: `dict` - 统计信息字典 - -**示例**: - -```python -stats = await ctx.memory.stats() -print(f"记忆库共有 {stats['total_items']} 条记录") -if 'ttl_entries' in stats: - print(f"其中 {stats['ttl_entries']} 条有过期时间") -if 'indexed_items' in stats: - print(f"已建立索引: {stats['indexed_items']}") -if 'embedded_items' in stats: - print(f"已向量化: {stats['embedded_items']}") -if 'dirty_items' in stats: - print(f"待重建索引: {stats['dirty_items']}") -``` - ---- - -## DBClient - KV 数据库客户端 - -提供键值存储能力,用于持久化插件数据。数据永久保存直到显式删除。 - -### 导入 - -```python -from astrbot_sdk.clients import DBClient -``` - -### 方法 - -#### `get(key)` - -获取指定键的值。 - -**参数**: -- `key` (`str`): 数据键名 - -**返回**: `Any | None` - 存储的值,键不存在则返回 None - -**示例**: - -```python -data = await ctx.db.get("user_settings") -if data: - print(data["theme"]) -``` - ---- - -#### `set(key, value)` - -设置键值对。 - -**参数**: -- `key` (`str`): 数据键名 -- `value` (`Any`): 要存储的 JSON 值 - -**示例**: - -```python -# 存储字典 -await ctx.db.set("user_settings", {"theme": "dark", "lang": "zh"}) - -# 存储列表 -await ctx.db.set("recent_commands", ["help", "status", "info"]) - -# 存储基本类型 -await ctx.db.set("greeted", True) -await ctx.db.set("counter", 42) -await ctx.db.set("last_seen", "2024-01-01T00:00:00Z") -``` - ---- - -#### `delete(key)` - -删除指定键的数据。 - -**参数**: -- `key` (`str`): 要删除的数据键名 - -**示例**: - -```python -await ctx.db.delete("user_settings") -``` - ---- - -#### `list(prefix=None)` - -列出匹配前缀的所有键。 - -**参数**: -- `prefix` (`str | None`): 键前缀过滤,None 表示列出所有键 - -**返回**: `list[str]` - 匹配的键名列表 - -**示例**: - -```python -# 列出所有用户设置相关的键 -keys = await ctx.db.list("user_") -# ["user_settings", "user_profile", "user_history"] - -# 列出所有键 -all_keys = await ctx.db.list() -``` - ---- - -#### `get_many(keys)` - -批量获取多个键的值。 - -**参数**: -- `keys` (`Sequence[str]`): 要读取的键列表 - -**返回**: `dict[str, Any | None]` - 字典,value 为对应值(不存在则为 None) - -**示例**: - -```python -values = await ctx.db.get_many(["user:1", "user:2", "user:3"]) -if values["user:1"] is None: - print("user:1 不存在") - -# 遍历结果 -for key, value in values.items(): - print(f"{key}: {value}") -``` - ---- - -#### `set_many(items)` - -批量写入多个键值对。 - -**参数**: -- `items` (`Mapping[str, Any] | Sequence[tuple[str, Any]]`): 键值对集合 - -**示例**: - -```python -# 使用字典 -await ctx.db.set_many({ - "user:1": {"name": "Alice"}, - "user:2": {"name": "Bob"}, - "user:3": {"name": "Charlie"} -}) - -# 使用元组列表 -await ctx.db.set_many([ - ("counter:1", 10), - ("counter:2", 20), - ("counter:3", 30) -]) -``` - ---- - -#### `watch(prefix=None)` - -订阅 KV 变更事件(流式)。 - -**参数**: -- `prefix` (`str | None`): 键前缀过滤 - -**返回**: 异步迭代器,产生变更事件 - -**事件格式**: `{"op": "set"|"delete", "key": str, "value": Any|None}` - -**示例**: - -```python -# 监听所有变更 -async for event in ctx.db.watch(): - print(f"{event['op']}: {event['key']}") - -# 监听特定前缀的变更 -async for event in ctx.db.watch("user:"): - if event["op"] == "set": - print(f"用户 {event['key']} 更新: {event['value']}") - else: - print(f"用户 {event['key']} 删除") -``` - ---- - -## PlatformClient - 平台消息客户端 - -提供向聊天平台发送消息和获取信息的能力。 - -### 导入 - -```python -from astrbot_sdk.clients import PlatformClient -``` - -### 方法 - -#### `send(session, text)` - -发送文本消息。 - -**参数**: -- `session` (`str | SessionRef | MessageSession`): 统一消息来源标识 -- `text` (`str`): 要发送的文本内容 - -**返回**: `dict[str, Any]` - 发送结果 - -**示例**: - -```python -# 使用字符串 UMO -await ctx.platform.send( - "qq:group:123456", - "大家好!" -) - -# 使用 MessageSession -from astrbot_sdk.message_session import MessageSession - -session = MessageSession( - platform_id="qq", - message_type="group", - session_id="123456" -) -await ctx.platform.send(session, "你好!") - -# 使用事件中的 session_id -await ctx.platform.send(event.session_id, "收到您的消息!") -``` - ---- - -#### `send_image(session, image_url)` - -发送图片消息。 - -**参数**: -- `session`: 会话标识 -- `image_url` (`str`): 图片 URL 或本地文件路径 - -**返回**: `dict[str, Any]` - 发送结果 - -**示例**: - -```python -# 使用 URL -await ctx.platform.send_image( - event.session_id, - "https://example.com/image.png" -) - -# 使用本地路径 -await ctx.platform.send_image( - "qq:private:789", - "/path/to/local/image.jpg" -) -``` - ---- - -#### `send_chain(session, chain)` - -发送富消息链。 - -**参数**: -- `session`: 会话标识 -- `chain` (`MessageChain | list[BaseMessageComponent] | list[dict]`): 消息链 - -**返回**: `dict[str, Any]` - 发送结果 - -**示例**: - -```python -from astrbot_sdk.message_components import Plain, Image - -# 使用 MessageChain -chain = MessageChain([ - Plain("你好 "), - At("123456"), - Plain("!"), -]) -await ctx.platform.send_chain(event.session_id, chain) - -# 使用组件列表 -await ctx.platform.send_chain( - event.session_id, - [Plain("文本"), Image(url="https://example.com/img.jpg")] -) - -# 使用序列化的 payload -await ctx.platform.send_chain( - event.session_id, - [ - {"type": "text", "data": {"text": "文本"}}, - {"type": "image", "data": {"url": "https://example.com/a.png"}} - ] -) -``` - ---- - -#### `send_by_session(session, content)` - -主动向指定会话发送消息。 - -**参数**: -- `session`: 会话标识 -- `content`: 消息内容(支持多种格式) - -**示例**: - -```python -# 发送文本 -await ctx.platform.send_by_session("qq:group:123456", "公告:...") - -# 发送消息链 -chain = MessageChain([Plain("重要通知"), Image.fromURL(...)]) -await ctx.platform.send_by_session("qq:group:123456", chain) -``` - ---- - -#### `send_by_id(platform_id, session_id, content, *, message_type)` - -主动向指定平台会话发送消息。 - -**参数**: -- `platform_id` (`str`): 平台 ID -- `session_id` (`str`): 会话 ID -- `content`: 消息内容 -- `message_type` (`str`): 消息类型(`"private"` 或 `"group"`) - -**示例**: - -```python -# 发送私聊消息 -await ctx.platform.send_by_id( - platform_id="qq", - session_id="123456", - content="Hello", - message_type="private" -) - -# 发送群消息 -await ctx.platform.send_by_id( - platform_id="qq", - session_id="789", - content="群公告", - message_type="group" -) -``` - ---- - -#### `get_members(session)` - -获取群组成员列表。 - -**参数**: -- `session`: 群组会话标识 - -**返回**: `list[dict]` - 成员信息列表 - -**示例**: - -```python -members = await ctx.platform.get_members("qq:group:123456") -for member in members: - print(f"{member['nickname']} ({member['user_id']})") -``` - ---- - -## FileServiceClient - 文件服务客户端 - -提供文件令牌注册与解析能力,用于跨进程文件传递。 - -### 导入 - -```python -from astrbot_sdk.clients import FileServiceClient, FileRegistration -``` - -### 方法 - -#### `register_file(path, timeout=None)` - -注册文件到文件服务,获取访问令牌。 - -**参数**: -- `path` (`str`): 文件路径 -- `timeout` (`float | None`): 超时时间(秒) - -**返回**: `str` - 文件访问令牌 - -**示例**: - -```python -token = await ctx.files.register_file("/path/to/file.jpg", timeout=3600) -``` - ---- - -#### `handle_file(token)` - -通过令牌解析文件路径。 - -**参数**: -- `token` (`str`): 文件访问令牌 - -**返回**: `str` - 文件路径 - -**示例**: - -```python -path = await ctx.files.handle_file(token) -with open(path, 'rb') as f: - data = f.read() -``` - ---- - -## HTTPClient - HTTP API 客户端 - -提供 Web API 注册能力,允许插件暴露自定义 HTTP 端点。 - -### 导入 - -```python -from astrbot_sdk.clients import HTTPClient -``` - -### 方法 - -#### `register_api(route, handler_capability=None, *, handler=None, methods=None, description="")` - -注册 Web API 端点。 - -**参数**: -- `route` (`str`): API 路由路径 -- `handler_capability` (`str | None`): 处理此路由的 capability 名称 -- `handler` (`Any | None`): 使用 `@provide_capability` 标记的方法引用 -- `methods` (`list[str] | None`): HTTP 方法列表 -- `description` (`str`): API 描述 - -**示例**: - -```python -from astrbot_sdk.decorators import provide_capability - -# 1. 声明处理 HTTP 请求的 capability -@provide_capability( - name="my_plugin.http_handler", - description="处理 /my-api 的 HTTP 请求" -) -async def handle_http_request(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - -# 2. 注册路由 -await ctx.http.register_api( - route="/my-api", - handler_capability="my_plugin.http_handler", - methods=["GET", "POST"], - description="我的 API" -) - -# 或使用 handler 参数 -await ctx.http.register_api( - route="/my-api", - handler=handle_http_request, - methods=["GET"] -) -``` - ---- - -#### `unregister_api(route, methods=None)` - -注销 Web API 端点。 - -**参数**: -- `route` (`str`): API 路由路径 -- `methods` (`list[str] | None`): HTTP 方法列表 - -**示例**: - -```python -await ctx.http.unregister_api("/my-api") -``` - ---- - -#### `list_apis()` - -列出当前插件注册的所有 API。 - -**返回**: `list[dict]` - API 列表 - -**示例**: - -```python -apis = await ctx.http.list_apis() -for api in apis: - print(f"{api['route']}: {api['methods']}") -``` - ---- - -## MetadataClient - 插件元数据客户端 - -提供插件元数据查询能力。 - -### 导入 - -```python -from astrbot_sdk.clients import MetadataClient, PluginMetadata -``` - -### 方法 - -#### `get_plugin(name)` - -获取指定插件的元数据。 - -**参数**: -- `name` (`str`): 插件名称 - -**返回**: `PluginMetadata | None` - 插件元数据 - -**示例**: - -```python -plugin = await ctx.metadata.get_plugin("another_plugin") -if plugin: - print(f"插件: {plugin.display_name}") - print(f"版本: {plugin.version}") -``` - ---- - -#### `list_plugins()` - -获取所有插件的元数据列表。 - -**返回**: `list[PluginMetadata]` - -**示例**: - -```python -plugins = await ctx.metadata.list_plugins() -for plugin in plugins: - print(f"{plugin.display_name} v{plugin.version} - {plugin.author}") -``` - ---- - -#### `get_current_plugin()` - -获取当前插件的元数据。 - -**返回**: `PluginMetadata | None` - -**示例**: - -```python -current = await ctx.metadata.get_current_plugin() -if current: - print(f"当前插件: {current.name} v{current.version}") -``` - ---- - -#### `get_plugin_config(name=None)` - -获取插件配置。 - -**参数**: -- `name` (`str | None`): 插件名称,None 表示当前插件 - -**返回**: `dict | None` - 插件配置字典 - -**注意**: 只能查询当前插件自己的配置 - -**示例**: - -```python -# 获取当前插件配置 -config = await ctx.metadata.get_plugin_config() -if config: - api_key = config.get("api_key") - -# 获取其他插件配置会抛 PermissionError -await ctx.metadata.get_plugin_config("other_plugin") -``` - ---- - -## ProviderClient - Provider 发现客户端 - -提供 Provider 发现和查询能力。 - -### 导入 - -```python -from astrbot_sdk.clients import ProviderClient -``` - -### 方法 - -#### `list_all()` - -列出所有聊天 Provider。 - -**返回**: `list[ProviderMeta]` - -**示例**: - -```python -providers = await ctx.providers.list_all() -for p in providers: - print(f"{p.id}: {p.model}") -``` - ---- - -#### `list_tts()` - -列出所有 TTS Provider。 - -**返回**: `list[ProviderMeta]` - ---- - -#### `list_stt()` - -列出所有 STT Provider。 - -**返回**: `list[ProviderMeta]` - ---- - -#### `list_embedding()` - -列出所有 Embedding Provider。 - -**返回**: `list[ProviderMeta]` - ---- - -#### `list_rerank()` - -列出所有 Rerank Provider。 - -**返回**: `list[ProviderMeta]` - ---- - -#### `get(provider_id)` - -获取指定 Provider 的代理。 - -**参数**: -- `provider_id` (`str`): Provider ID - -**返回**: `ProviderProxy | None` - ---- - -#### `get_using_chat(umo=None)` - -获取当前使用的聊天 Provider。 - -**参数**: -- `umo` (`str | None`): 统一消息来源标识 - -**返回**: `ProviderMeta | None` - ---- - -#### `get_using_tts(umo=None)` - -获取当前使用的 TTS Provider。 - ---- - -#### `get_using_stt(umo=None)` - -获取当前使用的 STT Provider。 - ---- - -## ProviderManagerClient - Provider 管理客户端 - -提供 Provider 的动态管理能力。 - -### 导入 - -```python -from astrbot_sdk.clients import ProviderManagerClient -``` - -### 方法 - -#### `set_provider(provider_id, provider_type, umo=None)` - -设置当前使用的 Provider。 - -**参数**: -- `provider_id` (`str`): Provider ID -- `provider_type` (`ProviderType | str`): Provider 类型 -- `umo` (`str | None`): 统一消息来源标识 - ---- - -#### `get_provider_by_id(provider_id)` - -通过 ID 获取 Provider 记录。 - ---- - -#### `load_provider(provider_config)` - -加载 Provider。 - ---- - -#### `create_provider(provider_config)` - -创建新 Provider。 - ---- - -#### `update_provider(origin_provider_id, new_config)` - -更新 Provider 配置。 - ---- - -#### `delete_provider(provider_id=None, provider_source_id=None)` - -删除 Provider。 - ---- - -#### `get_insts()` - -获取所有已管理的 Provider 实例。 - ---- - -#### `watch_changes()` - -订阅 Provider 变更事件(流式)。 - ---- - -## PersonaManagerClient - 人格管理客户端 - -提供人格(Persona)的增删改查能力。 - -### 导入 - -```python -from astrbot_sdk.clients import PersonaManagerClient -``` - -### 方法 - -#### `get_persona(persona_id)` - -获取指定人格。 - -当人格不存在时会抛出 `ValueError`,而不是返回 `None`。 - ---- - -#### `get_all_personas()` - -获取所有人脸列表。 - ---- - -#### `create_persona(params)` - -创建新人格。 - ---- - -#### `update_persona(persona_id, params)` - -更新人格。 - ---- - -#### `delete_persona(persona_id)` - -删除人格。 - ---- - -## ConversationManagerClient - 对话管理客户端 - -提供对话的创建、切换、更新、删除和查询能力。 - -### 导入 - -```python -from astrbot_sdk.clients import ConversationManagerClient -``` - -### 方法 - -#### `new_conversation(session, params=None)` - -创建新对话。 - ---- - -#### `switch_conversation(session, conversation_id)` - -切换当前对话。 - ---- - -#### `delete_conversation(session, conversation_id=None)` - -删除对话。 - ---- - -#### `get_conversation(session, conversation_id, create_if_not_exists=False)` - -获取对话。 - ---- - -#### `get_current_conversation(session, create_if_not_exists=False)` - -获取当前 session 正在使用的对话记录。 - -这个方法适合“跟随 AstrBot 原生当前会话状态”的插件,例如: -- 给当前会话切换 persona -- 判断当前主聊天是否已经在某个 persona 下 -- 在 `waiting_llm_request` / `llm_request` hook 中对当前对话做增强 - ---- - -#### `get_conversations(session=None, platform_id=None)` - -获取对话列表。 - ---- - -#### `update_conversation(session, conversation_id=None, params=None)` - -更新对话。 - ---- - -## KnowledgeBaseManagerClient - 知识库管理客户端 - -提供知识库的创建、查询和删除能力。 - -### 导入 - -```python -from astrbot_sdk.clients import KnowledgeBaseManagerClient -``` - -### 方法 - -#### `get_kb(kb_id)` - -获取知识库。 - ---- - -#### `create_kb(params)` - -创建新知识库。 - ---- - -#### `delete_kb(kb_id)` - -删除知识库。 - ---- - -## 使用示例 - -### 基本对话流程 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - reply = await ctx.llm.chat(event.message_content) - await ctx.platform.send(event.session_id, reply) -``` - -### 带历史的对话 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 从 memory 获取历史 - history_data = await ctx.memory.get(f"history:{event.session_id}") - history = history_data.get("messages", []) if history_data else [] - - # 对话 - reply = await ctx.llm.chat(event.message_content, history=history) - - # 保存新消息到历史 - history.append(ChatMessage(role="user", content=event.message_content)) - history.append(ChatMessage(role="assistant", content=reply)) - await ctx.memory.save(f"history:{event.session_id}", {"messages": history}) - - await ctx.platform.send(event.session_id, reply) -``` - -### 使用数据库持久化 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 获取用户配置 - config = await ctx.db.get(f"user_config:{event.sender_id}") - - if not config: - config = {"theme": "light", "lang": "zh"} - await ctx.db.set(f"user_config:{event.sender_id}", config) - - # 使用配置 - reply = f"你的主题设置是: {config['theme']}" - await ctx.platform.send(event.session_id, reply) -``` - ---- - -## 注意事项 - -1. 所有客户端方法都是异步的,需要使用 `await` -2. 远程调用可能失败,建议使用 try-except 处理 -3. Memory 适合语义搜索,DB 适合精确匹配 -4. 文件操作使用 file service 注册令牌 -5. 平台标识使用 UMO 格式:`"platform:instance:session_id"` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.clients` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/context.md b/astrbot-sdk/src/astrbot_sdk/docs/api/context.md deleted file mode 100644 index 9e8419041..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/context.md +++ /dev/null @@ -1,1422 +0,0 @@ -# Context 类 - 插件运行时上下文完整参考 - -## 概述 - -`Context` 是插件运行时的核心上下文对象,每个 handler 调用都会创建一个新的 Context 实例。Context 组合了所有 capability 客户端,提供统一的访问接口。 - -**模块路径**: `astrbot_sdk.context.Context` - ---- - -## 类定义 - -```python -@dataclass(slots=True) -class Context: - # 基本属性 - peer: Any # 协议对等端 - plugin_id: str # 插件 ID - logger: PluginLogger # 日志器 - cancel_token: CancelToken # 取消令牌 - - # 能力客户端 - llm: LLMClient # LLM 客户端 - memory: MemoryClient # 记忆客户端 - db: DBClient # 数据库客户端 - files: FileServiceClient # 文件服务客户端 - platform: PlatformClient # 平台客户端 - providers: ProviderClient # Provider 客户端 - provider_manager: ProviderManagerClient # Provider 管理客户端 - personas: PersonaManagerClient # 人格管理客户端 - conversations: ConversationManagerClient # 对话管理客户端 - kbs: KnowledgeBaseManagerClient # 知识库管理客户端 - http: HTTPClient # HTTP 客户端 - metadata: MetadataClient # 元数据客户端 - - # 系统工具 - _llm_tool_manager: LLMToolManager - _source_event_payload: dict[str, Any] - - # 别名 - persona_manager = personas - conversation_manager = conversations - kb_manager = kbs -``` - ---- - -## 导入方式 - -```python -# 从主模块导入(推荐) -from astrbot_sdk import Context - -# 从子模块导入 -from astrbot_sdk.context import Context - -# 常用配套导入 -from astrbot_sdk import MessageEvent # 消息事件 -from astrbot_sdk.decorators import on_command, on_message # 装饰器 -from astrbot_sdk.clients.llm import ChatMessage # 聊天消息(用于历史记录) -``` - ---- - -## 基本属性 - -### `peer` - -协议对等端,用于底层通信。 - -```python -# 类型: Any -# 说明: 内部使用,用于与 Core 通信 -``` - -### `plugin_id` - -当前插件的唯一标识符。 - -```python -# 类型: str -# 说明: 插件的名称,对应 plugin.yaml 中的 name 字段 - -ctx.logger.info(f"当前插件: {ctx.plugin_id}") -``` - -### `logger` - -绑定了插件 ID 的日志器。 - -```python -# 类型: PluginLogger -# 说明: 自动添加 plugin_id 上下文 - -# 不同级别的日志 -ctx.logger.debug("调试信息") -ctx.logger.info("普通信息") -ctx.logger.warning("警告信息") -ctx.logger.error("错误信息") - -# 绑定额外上下文 -logger = ctx.logger.bind(user_id="12345") -logger.info("用户操作") - -# 流式日志监听 -async for entry in ctx.logger.watch(): - print(f"[{entry.level}] {entry.message}") -``` - -### `cancel_token` - -取消令牌,用于长时间运行的任务中检查是否需要取消。 - -```python -# 类型: CancelToken - -# 检查是否取消 -ctx.cancel_token.raise_if_cancelled() - -# 触发取消 -ctx.cancel_token.cancel() - -# 等待取消信号 -await ctx.cancel_token.wait() - -# 检查状态 -if ctx.cancel_token.cancelled: - print("操作已取消") -``` - -**使用场景**: - -```python -async def long_operation(ctx: Context): - for item in large_list: - # 检查是否取消 - ctx.cancel_token.raise_if_cancelled() - - await process(item) -``` - ---- - -## 能力客户端 - -### 1. LLM 客户端 (ctx.llm) - -提供 AI 对话能力。 - -```python -# 类型: LLMClient -``` - -#### 方法 - -##### `chat()` - -简单对话。 - -```python -reply = await ctx.llm.chat("你好,介绍一下自己") - -# 带系统提示 -reply = await ctx.llm.chat( - "翻译成英文", - system="你是一个专业翻译助手" -) - -# 带对话历史 -from astrbot_sdk.clients.llm import ChatMessage - -history = [ - ChatMessage(role="user", content="我叫小明"), - ChatMessage(role="assistant", content="你好小明!"), -] -reply = await ctx.llm.chat("你记得我吗?", history=history) -``` - -##### `chat_raw()` - -获取完整响应对象。 - -```python -response = await ctx.llm.chat_raw("写一首诗", temperature=0.8) -print(f"生成文本: {response.text}") -print(f"Token 使用: {response.usage}") -print(f"结束原因: {response.finish_reason}") -``` - -##### `stream_chat()` - -流式对话。 - -```python -async for chunk in ctx.llm.stream_chat("讲一个故事"): - print(chunk, end="", flush=True) -``` - ---- - -### 2. Memory 客户端 (ctx.memory) - -提供语义搜索的记忆存储能力。 - -```python -# 类型: MemoryClient -``` - -#### 方法 - -##### `search()` - -搜索记忆。默认在有 embedding provider 时执行 hybrid 检索。 - -```python -results = await ctx.memory.search( - "用户喜欢什么颜色", - mode="hybrid", - limit=5, -) -for item in results: - print(item["key"], item["score"], item["match_type"]) -``` - -##### `save()` - -保存记忆。 - -```python -# 保存用户偏好 -await ctx.memory.save("user_pref", {"theme": "dark", "lang": "zh"}) - -# 使用关键字参数 -await ctx.memory.save("note", None, content="重要笔记", tags=["work"]) - -# 显式指定检索文本 -await ctx.memory.save( - "profile:alice", - { - "name": "Alice", - "embedding_text": "Alice 喜欢蓝色和海边", - }, -) -``` - -##### `get()` - -获取记忆。 - -```python -pref = await ctx.memory.get("user_pref") -if pref: - print(f"用户偏好主题: {pref.get('theme')}") -``` - -##### `save_with_ttl()` - -保存带过期时间的记忆。 - -```python -# 保存临时会话状态,1小时后过期 -await ctx.memory.save_with_ttl( - "session_temp", - {"state": "waiting"}, - ttl_seconds=3600 -) -``` - -##### `delete()` - -删除记忆。 - -```python -await ctx.memory.delete("old_note") -``` - -##### `stats()` - -查看记忆索引状态。 - -```python -stats = await ctx.memory.stats() -print(stats["total_items"], stats.get("embedded_items"), stats.get("dirty_items")) -``` - ---- - -### 3. DB 客户端 (ctx.db) - -提供键值存储能力,数据永久保存。 - -```python -# 类型: DBClient -``` - -#### 方法 - -##### `get() / set()` - -基本读写。 - -```python -# 读取 -data = await ctx.db.get("user_settings") -if data: - print(data["theme"]) - -# 写入 -await ctx.db.set("user_settings", {"theme": "dark", "lang": "zh"}) -await ctx.db.set("greeted", True) -``` - -##### `delete()` - -删除数据。 - -```python -await ctx.db.delete("user_settings") -``` - -##### `list()` - -列出键。 - -```python -keys = await ctx.db.list("user_") -# ["user_settings", "user_profile", "user_history"] -``` - -##### `get_many() / set_many()` - -批量操作。 - -```python -# 批量读取 -values = await ctx.db.get_many(["user:1", "user:2"]) - -# 批量写入 -await ctx.db.set_many({ - "user:1": {"name": "Alice"}, - "user:2": {"name": "Bob"} -}) -``` - -##### `watch()` - -监听变更事件。 - -```python -async for event in ctx.db.watch("user:"): - print(f"{event['op']}: {event['key']}") -``` - ---- - -### 4. Files 客户端 (ctx.files) - -提供文件令牌注册与解析能力。 - -```python -# 类型: FileServiceClient -``` - -#### 方法 - -##### `register_file()` - -注册文件并获取令牌。 - -```python -token = await ctx.files.register_file("/path/to/file.jpg", timeout=3600) -``` - -##### `handle_file()` - -通过令牌解析文件路径。 - -```python -path = await ctx.files.handle_file(token) -``` - ---- - -### 5. Platform 客户端 (ctx.platform) - -提供向聊天平台发送消息和获取信息的能力。 - -```python -# 类型: PlatformClient -``` - -#### 方法 - -##### `send()` - -发送文本消息。 - -```python -await ctx.platform.send("qq:group:123456", "大家好!") - -# 使用 MessageSession -from astrbot_sdk.message_session import MessageSession - -session = MessageSession( - platform_id="qq", - message_type="group", - session_id="123456" -) -await ctx.platform.send(session, "你好!") -``` - -##### `send_image()` - -发送图片。 - -```python -await ctx.platform.send_image( - event.session_id, - "https://example.com/image.png" -) -``` - -##### `send_chain()` - -发送消息链。 - -```python -from astrbot_sdk.message_components import Plain, Image - -chain = [Plain("文字"), Image(url="https://example.com/img.jpg")] -await ctx.platform.send_chain(event.session_id, chain) -``` - -##### `send_by_id()` - -通过 ID 发送。 - -```python -await ctx.platform.send_by_id( - platform_id="qq", - session_id="user123", - content="Hello", - message_type="private" -) -``` - -##### `get_members()` - -获取群组成员。 - -```python -members = await ctx.platform.get_members("qq:group:123456") -for member in members: - print(f"{member['nickname']} ({member['user_id']})") -``` - ---- - -### 6. Providers 客户端 (ctx.providers) - -提供 Provider 发现和查询能力。 - -```python -# 类型: ProviderClient -``` - -#### 方法 - -##### `list_all()` - -列出所有 Provider。 - -```python -providers = await ctx.providers.list_all() -for p in providers: - print(f"{p.id}: {p.model}") -``` - -##### `get_using_chat()` - -获取当前使用的聊天 Provider。 - -```python -provider = await ctx.providers.get_using_chat() -if provider: - print(f"当前使用: {provider.id}") -``` - -##### `list_tts() / list_stt() / list_embedding() / list_rerank()` - -列出特定类型的 Provider。 - -```python -tts_providers = await ctx.providers.list_tts() -stt_providers = await ctx.providers.list_stt() -``` - ---- - -### 7. Provider Manager 客户端 (ctx.provider_manager) - -提供 Provider 的动态管理能力。 - -```python -# 类型: ProviderManagerClient -``` - -#### 方法 - -##### `set_provider()` - -设置当前使用的 Provider。 - -```python -from astrbot_sdk.llm.entities import ProviderType - -await ctx.provider_manager.set_provider( - "my_provider", - ProviderType.TEXT_TO_TEXT, - umo=event.session_id -) -``` - -##### `get_provider_by_id()` - -获取 Provider 记录。 - -```python -record = await ctx.provider_manager.get_provider_by_id("my_provider") -``` - -##### `create_provider() / update_provider() / delete_provider()` - -管理 Provider。 - -```python -# 创建 -record = await ctx.provider_manager.create_provider({ - "id": "my_provider", - "type": "openai", - "model": "gpt-4" -}) - -# 更新 -record = await ctx.provider_manager.update_provider( - "my_provider", - {"model": "gpt-4-turbo"} -) - -# 删除 -await ctx.provider_manager.delete_provider(provider_id="my_provider") -``` - -##### `watch_changes()` - -监听 Provider 变更事件。 - -```python -async for event in ctx.provider_manager.watch_changes(): - print(f"Provider {event.provider_id} 变更") -``` - ---- - -### 8. Personas 客户端 (ctx.personas / ctx.persona_manager) - -提供人格管理能力。 - -```python -# 类型: PersonaManagerClient -``` - -#### 方法 - -##### `get_persona() / get_all_personas()` - -获取人格。 - -```python -# 获取单个人格 -persona = await ctx.personas.get_persona("assistant") - -# 获取所有人格 -personas = await ctx.personas.get_all_personas() -``` - -##### `create_persona() / update_persona() / delete_persona()` - -管理人格。 - -```python -from astrbot_sdk.clients import PersonaCreateParams - -# 创建 -persona = await ctx.personas.create_persona(PersonaCreateParams( - persona_id="assistant", - system_prompt="你是一个有用的助手。", - begin_dialogs=["你好,有什么可以帮助你的?"] -)) - -# 更新 -updated = await ctx.personas.update_persona( - "assistant", - PersonaUpdateParams(system_prompt="你是一个专业的编程助手。") -) - -# 删除 -await ctx.personas.delete_persona("old_persona") -``` - ---- - -### 9. Conversations 客户端 (ctx.conversations / ctx.conversation_manager) - -提供对话管理能力。 - -```python -# 类型: ConversationManagerClient -``` - -#### 方法 - -##### `new_conversation()` - -创建新对话。 - -```python -from astrbot_sdk.clients import ConversationCreateParams - -conv_id = await ctx.conversations.new_conversation( - event.session_id, - ConversationCreateParams( - title="新对话", - persona_id="assistant" - ) -) -``` - -##### `switch_conversation()` - -切换当前对话。 - -```python -await ctx.conversations.switch_conversation( - event.session_id, - "conv_123" -) -``` - -##### `delete_conversation()` - -删除对话。 - -```python -# 删除指定对话 -await ctx.conversations.delete_conversation( - event.session_id, - "conv_123" -) - -# 删除当前对话 -await ctx.conversations.delete_conversation(event.session_id) -``` - -##### `get_conversation() / get_current_conversation() / get_conversations()` - -获取对话。 - -```python -# 获取单个对话 -conv = await ctx.conversations.get_conversation( - event.session_id, - "conv_123", - create_if_not_exists=True -) - -# 获取当前选中的对话 -current = await ctx.conversations.get_current_conversation( - event.session_id, - create_if_not_exists=True, -) - -# 获取对话列表 -convs = await ctx.conversations.get_conversations(event.session_id) -``` - -##### `update_conversation()` - -更新对话。 - -```python -from astrbot_sdk.clients import ConversationUpdateParams - -await ctx.conversations.update_conversation( - event.session_id, - "conv_123", - ConversationUpdateParams(title="新标题") -) -``` - ---- - -### 10. Knowledge Bases 客户端 (ctx.kbs / ctx.kb_manager) - -提供知识库管理能力。 - -```python -# 类型: KnowledgeBaseManagerClient -``` - -#### 方法 - -##### `get_kb()` - -获取知识库。 - -```python -kb = await ctx.kbs.get_kb("my_kb") -if kb: - print(f"知识库: {kb.kb_name}") - print(f"文档数: {kb.doc_count}") -``` - -##### `create_kb()` - -创建知识库。 - -```python -from astrbot_sdk.clients import KnowledgeBaseCreateParams - -kb = await ctx.kbs.create_kb(KnowledgeBaseCreateParams( - kb_name="技术文档", - embedding_provider_id="openai_embedding", - description="存储技术文档", - emoji="📚" -)) -``` - -##### `delete_kb()` - -删除知识库。 - -```python -deleted = await ctx.kbs.delete_kb("my_kb") -if deleted: - print("知识库已删除") -``` - ---- - -### 11. HTTP 客户端 (ctx.http) - -提供 Web API 注册能力。 - -```python -# 类型: HTTPClient -``` - -#### 方法 - -##### `register_api()` - -注册 API 端点。 - -```python -from astrbot_sdk.decorators import provide_capability - -@provide_capability( - name="my_plugin.http_handler", - description="处理 HTTP 请求" -) -async def handle_http_request(request_id: str, payload: dict, cancel_token): - return {"status": 200, "body": {"result": "ok"}} - -await ctx.http.register_api( - route="/my-api", - handler=handle_http_request, - methods=["GET", "POST"], - description="我的 API" -) -``` - -##### `unregister_api()` - -注销 API。 - -```python -await ctx.http.unregister_api("/my-api") -``` - -##### `list_apis()` - -列出当前插件注册的所有 API。 - -```python -apis = await ctx.http.list_apis() -for api in apis: - print(f"{api['route']}: {api['methods']}") -``` - ---- - -### 12. Metadata 客户端 (ctx.metadata) - -提供插件元数据查询能力。 - -```python -# 类型: MetadataClient -``` - -#### 方法 - -##### `get_plugin()` - -获取指定插件信息。 - -```python -plugin = await ctx.metadata.get_plugin("another_plugin") -if plugin: - print(f"插件: {plugin.display_name}") - print(f"版本: {plugin.version}") - print(f"作者: {plugin.author}") -``` - -##### `list_plugins()` - -列出所有插件。 - -```python -plugins = await ctx.metadata.list_plugins() -for plugin in plugins: - print(f"{plugin.display_name} v{plugin.version} - {plugin.author}") -``` - -##### `get_current_plugin()` - -获取当前插件信息。 - -```python -current = await ctx.metadata.get_current_plugin() -if current: - print(f"当前插件: {current.name} v{current.version}") -``` - -##### `get_plugin_config()` - -获取插件配置。 - -```python -config = await ctx.metadata.get_plugin_config() -if config: - api_key = config.get("api_key") -``` - -**注意**: 只能查询当前插件自己的配置 - ---- - -### 13. Session Plugins 客户端 (ctx.session_plugins) - -提供会话级别的插件状态管理能力。 - -```python -# 类型: SessionPluginManager -``` - -#### 方法 - -##### `is_plugin_enabled_for_session()` - -检查插件是否对指定会话启用。 - -```python -enabled = await ctx.session_plugins.is_plugin_enabled_for_session( - event, # 可以是 event, session 字符串, 或 MessageSession - "my_plugin" -) -``` - -##### `filter_handlers_by_session()` - -过滤会话启用的处理器。 - -```python -from astrbot_sdk.clients.registry import HandlerMetadata - -enabled_handlers = await ctx.session_plugins.filter_handlers_by_session( - event, - all_handlers -) -``` - ---- - -### 14. Session Services 客户端 (ctx.session_services) - -提供会话级别的 LLM/TTS 服务状态管理能力。 - -```python -# 类型: SessionServiceManager -``` - -#### 方法 - -##### `is_llm_enabled_for_session()` - -检查 LLM 是否对指定会话启用。 - -```python -enabled = await ctx.session_services.is_llm_enabled_for_session(event) -if not enabled: - await event.reply("LLM 服务已禁用") -``` - -##### `set_llm_status_for_session()` - -设置 LLM 服务状态。 - -```python -# 启用 LLM -await ctx.session_services.set_llm_status_for_session(event, True) - -# 禁用 LLM -await ctx.session_services.set_llm_status_for_session(event, False) -``` - -##### `should_process_llm_request()` - -判断是否应该处理 LLM 请求。 - -```python -if await ctx.session_services.should_process_llm_request(event): - response = await ctx.llm.chat("...") -``` - ---- - -## 系统工具方法 - -### `get_data_dir()` - -获取插件数据目录路径。 - -```python -data_dir = await ctx.get_data_dir() -print(f"数据目录: {data_dir}") -``` - -**返回**: `Path` - 数据目录的 Path 对象 - ---- - -### `text_to_image()` - -将文本渲染为图片。 - -```python -url = await ctx.text_to_image("Hello World", return_url=True) -``` - -**参数**: -- `text`: 要渲染的文本 -- `return_url`: 是否返回 URL(False 则返回本地路径) - -**返回**: `str` - 图片 URL 或路径 - ---- - -### `html_render()` - -渲染 HTML 模板。 - -```python -url = await ctx.html_render( - tmpl="

{{ title }}

", - data={"title": "标题"} -) -``` - -**参数**: -- `tmpl`: HTML 模板内容 -- `data`: 模板数据 -- `return_url`: 是否返回 URL -- `options`: 渲染选项 - -**返回**: `str` - 渲染结果 URL 或路径 - ---- - -### `send_message()` - -向会话发送消息。 - -```python -await ctx.send_message(event.session_id, "消息内容") -``` - -**参数**: -- `session`: 会话标识 -- `content`: 消息内容(支持多种格式) - ---- - -### `send_message_by_id()` - -通过 ID 向平台发送消息。 - -```python -await ctx.send_message_by_id( - type="private", - id="user123", - content="Hello", - platform="qq" -) -``` - ---- - -### `register_task()` - -注册后台任务。 - -```python -async def background_work(): - while True: - await asyncio.sleep(60) - ctx.logger.info("每分钟执行一次") - -task = await ctx.register_task(background_work(), "定时任务") -``` - -**参数**: -- `task`: 可等待对象 -- `desc`: 任务描述 - -**返回**: `asyncio.Task` - 任务对象 - -**注意**: 任务失败会自动记录日志,不会影响插件主流程 - ---- - -## LLM Tool 管理方法 - -### `get_llm_tool_manager()` - -获取 LLM Tool 管理器。 - -```python -manager = ctx.get_llm_tool_manager() -``` - ---- - -### `add_llm_tools()` - -添加 LLM 工具规范。 - -```python -from astrbot_sdk.llm.tools import LLMToolSpec - -tool_spec = LLMToolSpec( - name="my_tool", - description="我的工具", - parameters_schema={...} -) - -await ctx.add_llm_tools(tool_spec) -``` - ---- - -### `activate_llm_tool() / deactivate_llm_tool()` - -激活/停用 LLM 工具。 - -```python -await ctx.activate_llm_tool("my_tool") -await ctx.deactivate_llm_tool("my_tool") -``` - ---- - -### `register_llm_tool()` - -注册可执行的 LLM 工具。 - -```python -async def search_weather(location: str) -> str: - return f"{location} 今天晴天" - -await ctx.register_llm_tool( - name="search_weather", - parameters_schema={ - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名称"} - }, - "required": ["location"] - }, - desc="搜索天气信息", - func_obj=search_weather, - active=True -) -``` - ---- - -### `unregister_llm_tool()` - -取消注册 LLM 工具。 - -```python -await ctx.unregister_llm_tool("my_tool") -``` - ---- - -## 高级方法 - -### `tool_loop_agent()` - -执行 Agent 工具循环。 - -**签名**: -```python -async def tool_loop_agent( - self, - request: ProviderRequest | None = None, - **kwargs: Any -) -> LLMResponse -``` - -**参数**: -- `request`: ProviderRequest 对象,包含请求配置 -- `**kwargs`: 额外的请求参数,会自动合并到 request - -**返回**: `LLMResponse` - 包含工具调用结果的完整响应 - -**示例**: - -```python -from astrbot_sdk.llm.entities import ProviderRequest - -response = await ctx.tool_loop_agent( - request=ProviderRequest( - prompt="搜索天气", - system_prompt="你是一个助手" - ) -) -print(response.text) -``` - ---- - -### `register_commands()` - -注册命令(仅在 `astrbot_loaded` 或 `platform_loaded` 事件中可用)。 - -**签名**: -```python -async def register_commands( - self, - command_name: str, - handler_full_name: str, - *, - desc: str = "", - priority: int = 0, - use_regex: bool = False, - ignore_prefix: bool = False, -) -> None -``` - -**参数**: -- `command_name`: 命令名称 -- `handler_full_name`: 处理函数的完整名称(如 `module.handler_name`) -- `desc`: 命令描述 -- `priority`: 优先级 -- `use_regex`: 是否使用正则匹配 -- `ignore_prefix`: 是否忽略前缀(SDK 中不支持) - -**异常**: -- `AstrBotError`: 如果在非加载事件中调用或设置 `ignore_prefix=True` - -**示例**: - -```python -@on_event("astrbot_loaded") -async def on_load(self, event, ctx: Context): - await ctx.register_commands( - command_name="my_cmd", - handler_full_name="my_module.handle_cmd", - desc="我的命令", - priority=10 - ) -``` - ---- - -### `get_platform()` - -获取指定类型的平台兼容层实例。 - -**签名**: -```python -async def get_platform(self, platform_type: str) -> PlatformCompatFacade | None -``` - -**参数**: -- `platform_type`: 平台类型(如 "qq", "telegram") - -**返回**: `PlatformCompatFacade | None` - 平台兼容层实例 - -**示例**: - -```python -platform = await ctx.get_platform("qq") -if platform: - await platform.send_by_session("session_id", "消息") -``` - ---- - -### `get_platform_inst()` - -获取指定 ID 的平台兼容层实例。 - -**签名**: -```python -async def get_platform_inst(self, platform_id: str) -> PlatformCompatFacade | None -``` - -**参数**: -- `platform_id`: 平台实例 ID - -**返回**: `PlatformCompatFacade | None` - 平台兼容层实例 - ---- - -## PlatformCompatFacade - -平台兼容层类,提供安全的平台元信息和主动发送能力。 - -### 属性 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `id` | `str` | 平台实例 ID | -| `name` | `str` | 平台名称 | -| `type` | `str` | 平台类型 | -| `status` | `PlatformStatus` | 平台状态 | -| `errors` | `list[PlatformError]` | 错误列表 | -| `last_error` | `PlatformError \| None` | 最近错误 | -| `unified_webhook` | `bool` | 是否统一 webhook | - -### 方法 - -#### `send()` - -发送消息。 - -```python -await platform.send("session_id", "消息内容") -``` - -#### `send_by_session()` - -通过会话发送消息。 - -```python -await platform.send_by_session("platform:private:123", "消息") -``` - -#### `send_by_id()` - -通过 ID 发送消息。 - -```python -await platform.send_by_id("user123", "消息", message_type="private") -``` - -#### `refresh()` - -刷新平台状态。 - -```python -await platform.refresh() -``` - -#### `clear_errors()` - -清除平台错误。 - -```python -await platform.clear_errors() -``` - -#### `get_stats()` - -获取平台统计信息。 - -```python -stats = await platform.get_stats() -``` - ---- - -## 使用示例 - -### 1. 基本对话流程 - -```python -from astrbot_sdk.decorators import on_message - -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - reply = await ctx.llm.chat(event.message_content) - await ctx.platform.send(event.session_id, reply) -``` - ---- - -### 2. 带历史的对话 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 从 memory 获取历史 - history_data = await ctx.memory.get(f"history:{event.session_id}") - history = history_data.get("messages", []) if history_data else [] - - # 对话 - reply = await ctx.llm.chat(event.message_content, history=history) - - # 保存新消息到历史 - history.append(ChatMessage(role="user", content=event.message_content)) - history.append(ChatMessage(role="assistant", content=reply)) - await ctx.memory.save(f"history:{event.session_id}", {"messages": history}) - - await ctx.platform.send(event.session_id, reply) -``` - ---- - -### 3. 使用数据库持久化 - -```python -@on_message() -async def handle_message(event: MessageEvent, ctx: Context): - # 获取用户配置 - config = await ctx.db.get(f"user_config:{event.sender_id}") - - if not config: - config = {"theme": "light", "lang": "zh"} - await ctx.db.set(f"user_config:{event.sender_id}", config) - - # 使用配置 - reply = f"你的主题设置是: {config['theme']}" - await ctx.platform.send(event.session_id, reply) -``` - ---- - -### 4. 注册 Web API - -```python -from astrbot_sdk.decorators import provide_capability - -@provide_capability( - name="my_plugin.get_status", - description="获取插件状态", -) -async def get_status(request_id: str, payload: dict, cancel_token): - return {"status": "running", "version": "1.0.0"} - -@on_command("setup_api") -async def setup_api(event: MessageEvent, ctx: Context): - await ctx.http.register_api( - route="/status", - handler=get_status, - methods=["GET"] - ) - await ctx.platform.send(event.session_id, "API 已注册") -``` - ---- - -## 注意事项 - -1. **跨进程通信**: Context 通过 capability 协议与核心通信,所有方法调用都是异步的 - -2. **插件隔离**: 每个插件有独立的 Context 实例,数据和配置是隔离的 - -3. **取消处理**: 长时间运行的操作应定期检查 `ctx.cancel_token.raise_if_cancelled()` - -4. **错误处理**: 所有远程调用都可能失败,建议使用 try-except 处理 - -5. **Memory vs DB**: - - Memory: 语义搜索,适合 AI 上下文 - - DB: 精确匹配,适合结构化数据 - -6. **文件操作**: 使用 `ctx.files` 注册文件令牌,不要直接传递本地路径 - -7. **平台标识**: 使用 UMO(统一消息来源标识)格式:`"platform:instance:session_id"` - -8. **配置访问**: `get_plugin_config()` 只支持查询当前插件自己的配置 - ---- - -## 相关模块 - -- **LLM 客户端**: `astrbot_sdk.clients.llm.LLMClient` -- **Memory 客户端**: `astrbot_sdk.clients.memory.MemoryClient` -- **DB 客户端**: `astrbot_sdk.clients.db.DBClient` -- **Platform 客户端**: `astrbot_sdk.clients.platform.PlatformClient` -- **日志器**: `astrbot_sdk._internal.plugin_logger.PluginLogger` -- **取消令牌**: `astrbot_sdk.context.CancelToken` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.context.Context` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/decorators.md b/astrbot-sdk/src/astrbot_sdk/docs/api/decorators.md deleted file mode 100644 index 3b19e31e8..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/decorators.md +++ /dev/null @@ -1,1217 +0,0 @@ -# 装饰器 - 事件处理注册完整参考 - -## 概述 - -装饰器是 AstrBot SDK 中用于注册事件处理器的核心机制。通过装饰器标记方法,SDK 会自动收集这些方法并在适当时机调用它们。 - -**模块路径**: `astrbot_sdk.decorators` - ---- - -## 目录 - -- [事件触发装饰器](#事件触发装饰器) -- [修饰器装饰器](#修饰器装饰器) -- [过滤器装饰器](#过滤器装饰器) -- [限制器装饰器](#限制器装饰器) -- [能力暴露装饰器](#能力暴露装饰器) -- [LLM 工具装饰器](#llm-工具装饰器) -- [使用示例](#使用示例) - ---- - -## 导入方式 - -```python -# 从主模块导入(推荐) -from astrbot_sdk.decorators import ( - # 事件触发 - on_command, - on_message, - on_event, - on_schedule, - # 修饰器 - require_admin, - # 过滤器 - platforms, - message_types, - group_only, - private_only, - # 限制器 - rate_limit, - cooldown, - # 能力暴露 - provide_capability, - # LLM 工具 - register_llm_tool, - register_agent, -) - -# 或者按需导入 -from astrbot_sdk.decorators import on_command, on_message -``` - ---- - -## 事件触发装饰器 - -### @on_command - -命令触发装饰器,当用户输入指定命令时触发。 - -#### 签名 - -```python -def on_command( - command: str | Sequence[str], - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `command` | `str \| Sequence[str]` | 是 | 命令名称(不包含前缀符),可传入单个命令或命令列表 | -| `aliases` | `list[str] \| None` | 否 | 命令别名列表 | -| `description` | `str \| None` | 否 | 命令描述,用于帮助信息生成 | - -#### 示例 - -```python -# 简单命令 -@on_command("hello") -async def hello(self, event: MessageEvent, ctx: Context): - await event.reply("Hello, World!") - -# 带别名 -@on_command("echo", aliases=["repeat", "say"]) -async def echo(self, event: MessageEvent, text: str): - await event.reply(f"你说: {text}") - -# 带描述 -@on_command("help", description="显示帮助信息") -async def help(self, event: MessageEvent, ctx: Context): - await event.reply("可用命令: /hello") - -# 批量命令 -@on_command(["start", "begin"]) -async def start(self, event: MessageEvent, ctx: Context): - await event.reply("开始执行...") -``` - -#### 注意事项 - -1. 命令名称不应包含前缀符(如 `/`),框架会自动处理 -2. 传入命令列表时,第一个命令作为主命令名,其余作为别名 -3. `aliases` 参数中的别名会与命令列表合并,重复项会自动去重 -4. 命令名不能为空字符串 - ---- - -### @on_message - -消息触发装饰器,当消息匹配指定条件时触发。 - -#### 签名 - -```python -def on_message( - *, - regex: str | None = None, - keywords: list[str] | None = None, - platforms: list[str] | None = None, - message_types: list[str] | None = None, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需* | 说明 | -|------|------|--------|------| -| `regex` | `str \| None` | 否* | 正则表达式模式 | -| `keywords` | `list[str] \| None` | 否* | 关键词列表(任一匹配即触发) | -| `platforms` | `list[str] \| None` | 否 | 限定平台列表 | -| `message_types` | `list[str] \| None` | 否 | 限定消息类型(`"group"`, `"private"`) | - -*注: `regex` 和 `keywords` 至少需要提供一个 - -#### 示例 - -```python -# 关键词匹配 -@on_message(keywords=["帮助", "help"]) -async def help(self, event: MessageEvent, ctx: Context): - await event.reply("可用命令: /hello") - -# 正则匹配 -@on_message(regex=r"\d{4,}") -async def number(self, event: MessageEvent, ctx: Context): - await event.reply("检测到数字!") - -# 多条件过滤 -@on_message( - keywords=["天气"], - platforms=["qq"], - message_types=["private"] -) -async def weather(self, event: MessageEvent, ctx: Context): - await event.reply("请输入城市名称查询天气") - -# 组合使用 -@on_message(regex=r"^打卡") -async def check_in(self, event: MessageEvent, ctx: Context): - await event.reply(f"{event.sender_name} 打卡成功!") -``` - -#### 注意事项 - -1. 正则表达式使用 Python `re` 模块语法 -2. 关键词匹配是包含匹配,不是精确匹配 -3. 不能与 `@platforms()` 装饰器混用(会有 `ValueError`) -4. 不能与 `@group_only()` / `@private_only()` / `@message_types()` 混用 - ---- - -### @on_event - -事件触发装饰器,用于处理非消息类型的系统事件。 - -#### 签名 - -```python -def on_event(event_type: str) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `event_type` | `str` | 是 | 事件类型标识 | - -#### 示例 - -```python -# 群成员加入事件 -@on_event("group_member_join") -async def welcome(self, event, ctx: Context): - await ctx.platform.send(event.group_id, f"欢迎 {event.user_id}!") - -# 群成员离开事件 -@on_event("group_member_decrease") -async def goodbye(self, event, ctx: Context): - await ctx.platform.send(event.group_id, f"再见 {event.user_id}") - -# 好友请求事件 -@on_event("friend_request") -async def handle_request(self, event, ctx: Context): - await ctx.platform.send(event.user_id, "已自动通过好友请求") -``` - -#### LLM Pipeline Hooks - -`@on_event` 也用于挂接 AstrBot 原生消息处理链路中的系统事件。 - -常见事件及可注入对象: - -| 事件名 | 常见可注入参数 | 是否可修改主链路 | -|------|------|------| -| `waiting_llm_request` | `MessageEvent`, `Context` | 间接可修改,例如切换当前对话 persona | -| `llm_request` | `MessageEvent`, `Context`, `ProviderRequest` | 是,可直接修改 `ProviderRequest` | -| `llm_response` | `MessageEvent`, `Context`, `LLMResponse` | 否,适合观察和提取回复内容 | -| `decorating_result` | `MessageEvent`, `Context`, `MessageEventResult` | 是,可直接修改结果消息链 | -| `after_message_sent` | `MessageEvent`, `Context` | 否,适合落库、记忆、统计 | -| `calling_func_tool` | `MessageEvent`, `Context` | 否,可读取 `event.raw["tool_name"]` / `event.raw["tool_args"]` | -| `using_llm_tool` | `MessageEvent`, `Context` | 否,可读取 `event.raw["tool_name"]` / `event.raw["tool_args"]` | -| `llm_tool_respond` | `MessageEvent`, `Context` | 否,可读取 `event.raw["tool_name"]` / `event.raw["tool_result"]` | -| `plugin_error` | `MessageEvent`, `Context` | 否,可读取 `event.raw["plugin_name"]` / `event.raw["error"]` | - -最小示例: - -```python -from astrbot_sdk import Context, MessageEvent -from astrbot_sdk.decorators import on_event -from astrbot_sdk.llm.entities import ProviderRequest - -@on_event("llm_request") -async def add_memory(self, event: MessageEvent, ctx: Context, request: ProviderRequest): - del event, ctx - request.system_prompt = (request.system_prompt or "") + "\n\nmemory: user likes tea" -``` - -完整示例: - -```python -from astrbot_sdk import Context, MessageEvent, Star -from astrbot_sdk.clients.llm import LLMResponse -from astrbot_sdk.clients.managers import ConversationUpdateParams -from astrbot_sdk.decorators import on_event -from astrbot_sdk.llm.entities import ProviderRequest -from astrbot_sdk.message_result import MessageEventResult -from astrbot_sdk.message_components import Plain - -class PersonaSample(Star): - @on_event("waiting_llm_request") - async def ensure_persona(self, event: MessageEvent, ctx: Context) -> None: - conversation = await ctx.conversations.get_current_conversation( - event.session_id, - create_if_not_exists=True, - ) - if conversation is None or conversation.persona_id == "girlfriend": - return - await ctx.conversations.update_conversation( - event.session_id, - conversation.conversation_id, - ConversationUpdateParams(persona_id="girlfriend"), - ) - - @on_event("llm_request") - async def inject_context( - self, - event: MessageEvent, - ctx: Context, - request: ProviderRequest, - ) -> None: - memories = await ctx.memory.search(event.text, limit=3) - facts = [] - for item in memories: - value = item.get("value") - if isinstance(value, dict) and value.get("content"): - facts.append(f"- {value['content']}") - if facts: - request.system_prompt = (request.system_prompt or "") + "\n\n" + "\n".join(facts) - - @on_event("llm_response") - async def capture_reply( - self, - event: MessageEvent, - ctx: Context, - response: LLMResponse, - ) -> None: - del ctx - if response.text: - event.set_extra("last_reply", response.text) - - @on_event("decorating_result") - async def decorate( - self, - event: MessageEvent, - ctx: Context, - result: MessageEventResult, - ) -> None: - del event, ctx - result.chain.append(Plain("\n[persona active]", convert=False)) - - @on_event("after_message_sent") - async def persist(self, event: MessageEvent, ctx: Context) -> None: - reply = str(event.get_extra("last_reply", "") or "").strip() - if not reply: - reply = str(event.get_sent_message_outline() or "").strip() - if reply: - await ctx.db.set("sample:last_reply", reply) -``` - -#### 注意事项 - -1. 可用于处理平台事件,也可用于处理 AstrBot 原生消息链路中的系统事件(如 `llm_request`) -2. 不能与 `@rate_limit` 或 `@cooldown` 一起使用 -3. 不同平台的事件类型可能不同,需要查阅平台文档 -4. `llm_request` 和 `decorating_result` 注入的是可变对象,修改会回写到 AstrBot 主链路 -5. `llm_response` 主要用于观测和提取结果,不应用来替代主回复流程 -6. 请求范围内的 JSON-safe `event.set_extra()` 数据会在同一次请求的 SDK hooks 之间保留;非 JSON-safe 值只在当前 handler 内可见 -7. `after_message_sent` 会保留 `event.text` 作为原始用户输入;读取机器人实际发送内容时,优先使用 `event.get_sent_message_outline()` 和 `event.get_sent_messages()` - -#### 系统事件附加字段 - -- `calling_func_tool` / `using_llm_tool`: `event.raw["tool_name"]`, `event.raw["tool_args"]` -- `llm_tool_respond`: `event.raw["tool_name"]`, `event.raw["tool_args"]`, `event.raw["tool_result"]` -- `plugin_error`: `event.raw["plugin_name"]`, `event.raw["handler_name"]`, `event.raw["error"]`, `event.raw["traceback"]` -- `after_message_sent`: `event.get_sent_message_outline()`, `event.get_sent_messages()` - ---- - -### @on_schedule - -定时任务装饰器,按指定时间间隔或 cron 表达式触发。 - -#### 签名 - -```python -def on_schedule( - *, - cron: str | None = None, - interval_seconds: int | None = None, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需* | 说明 | -|------|------|--------|------| -| `cron` | `str \| None` | 否* | cron 表达式(如 `"0 8 * * *"` 表示每天 8:00) | -| `interval_seconds` | `int \| None` | 否* | 执行间隔(秒) | - -*注: `cron` 和 `interval_seconds` 必须且只能提供一个 - -#### 示例 - -```python -# 固定间隔(每小时执行) -@on_schedule(interval_seconds=3600) -async def hourly_check(self, ctx: Context): - ctx.logger.info("每小时执行一次") - -# cron 表达式(每天 8:00) -@on_schedule(cron="0 8 * * *") -async def morning_greeting(self, ctx: Context): - await ctx.platform.send("group_123", "早上好!") - -# 每2小时 -@on_schedule(cron="0 */2 * * *") -async def bi_hourly_task(self, ctx: Context): - pass - -# 工作日 9:00-17:00 每小时 -@on_schedule(cron="0 9-17 * * 1-5") -async def work_hours_check(self, ctx: Context): - pass -``` - -#### cron 表达式格式 - -``` -分钟 小时 日 月 星期 -* * * * * - -示例: -0 8 * * * # 每天 8:00 -0 */2 * * * # 每2小时 -0 9-17 * * 1-5 # 工作日 9:00-17:00 每小时 -*/10 * * * * # 每10分钟 -``` - -#### 注意事项 - -1. cron 表达式格式: `分钟 小时 日 月 星期` -2. 不能与 `@rate_limit` 或 `@cooldown` 一起使用 -3. 定时任务的 handler 不接收 `MessageEvent` 参数 -4. `interval_seconds` 最小值为 60(1分钟) - ---- - -## 修饰器装饰器 - -### @require_admin - -管理员权限装饰器,限制只有管理员才能调用。 - -#### 签名 - -```python -def require_admin(func: HandlerCallable) -> HandlerCallable -``` - -#### 示例 - -```python -from astrbot_sdk.decorators import on_command, require_admin - -@on_command("shutdown") -@require_admin -async def shutdown(self, event: MessageEvent, ctx: Context): - await event.reply("正在关闭系统...") -``` - -#### 注意事项 - -1. 必须放在事件触发装饰器(如 `@on_command`)之后 -2. 非管理员用户触发时,handler 不会被调用 -3. 别名: `@admin_only()` 功能完全相同 - ---- - -## 过滤器装饰器 - -### @platforms - -限定平台装饰器,只在指定平台上触发。 - -#### 签名 - -```python -def platforms(*names: str) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `*names` | `str` | 是 | 平台名称(可变参数) | - -#### 示例 - -```python -@on_command("qq_only") -@platforms("qq") -async def qq_only(self, event: MessageEvent, ctx: Context): - await event.reply("这是 QQ 专属命令") - -@on_command("multi") -@platforms("qq", "telegram", "discord") -async def multi(self, event: MessageEvent, ctx: Context): - await event.reply("支持多平台") -``` - ---- - -### @message_types - -限定消息类型装饰器。 - -#### 签名 - -```python -def message_types(*types: str) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 示例 - -```python -@on_command("group_only") -@message_types("group") -async def group_only(self, event: MessageEvent, ctx: Context): - await event.reply("这是群聊命令") -``` - ---- - -### @group_only - -仅群聊装饰器。 - -#### 签名 - -```python -def group_only() -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 示例 - -```python -@on_command("group_admin") -@group_only() -async def group_admin(self, event: MessageEvent, ctx: Context): - await event.reply("这是群聊管理命令") -``` - -#### 注意事项 - -功能等同于 `@message_types("group")` - ---- - -### @private_only - -仅私聊装饰器。 - -#### 签名 - -```python -def private_only() -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 示例 - -```python -@on_command("private_chat") -@private_only() -async def private_only(self, event: MessageEvent, ctx: Context): - await event.reply("这是私聊命令") -``` - ---- - -## 限制器装饰器 - -### @rate_limit - -速率限制装饰器,限制时间窗口内的调用次数。 - -#### 签名 - -```python -def rate_limit( - limit: int, - window: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 默认值 | 说明 | -|------|------|------|--------|------| -| `limit` | `int` | 是 | - | 时间窗口内最大调用次数 | -| `window` | `float` | 是 | - | 时间窗口大小(秒) | -| `scope` | `LimiterScope` | 否 | `"session"` | 限制范围 | -| `behavior` | `LimiterBehavior` | 否 | `"hint"` | 触发限制后的行为 | -| `message` | `str \| None` | 否 | `None` | 自定义提示消息 | - -**scope 可选值**: -- `"session"` - 会话级别 -- `"user"` - 用户级别 -- `"group"` - 群组级别 -- `"global"` - 全局级别 - -**behavior 可选值**: -- `"hint"` - 返回提示消息 -- `"silent"` - 静默忽略 -- `"error"` - 抛出异常 - -#### 示例 - -```python -# 每分钟最多5次 -@on_command("search") -@rate_limit(5, 60) -async def search(self, event: MessageEvent, ctx: Context): - await event.reply("搜索结果...") - -# 每用户每小时3次 -@on_command("draw") -@rate_limit(3, 3600, scope="user") -async def draw(self, event: MessageEvent, ctx: Context): - await event.reply("绘图结果...") - -# 全局限制,自定义消息 -@on_command("global") -@rate_limit( - 10, 60, - scope="global", - message="操作过于频繁,请稍后再试" -) -async def global_action(self, event: MessageEvent, ctx: Context): - await event.reply("执行全局操作") -``` - ---- - -### @cooldown - -冷却时间装饰器,限制连续调用的间隔。 - -#### 签名 - -```python -def cooldown( - seconds: float, - *, - scope: LimiterScope = "session", - behavior: LimiterBehavior = "hint", - message: str | None = None, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 默认值 | 说明 | -|------|------|------|--------|------| -| `seconds` | `float` | 是 | - | 冷却时间(秒) | -| `scope` | `LimiterScope` | 否 | `"session"` | 限制范围 | -| `behavior` | `LimiterBehavior` | 否 | `"hint"` | 触发限制后的行为 | -| `message` | `str \| None` | 否 | `None` | 自定义提示消息 | - -#### 示例 - -```python -# 30秒冷却 -@on_command("cast_skill") -@cooldown(30) -async def cast_skill(self, event: MessageEvent, ctx: Context): - await event.reply("技能施放成功!") - -# 每用户24小时冷却 -@on_command("daily_reward") -@cooldown(86400, scope="user") -async def daily_reward(self, event: MessageEvent, ctx: Context): - await event.reply("领取每日奖励!") - -# 群组5分钟冷却 -@on_command("group_activity") -@cooldown(300, scope="group") -async def group_activity(self, event: MessageEvent, ctx: Context): - await event.reply("群活动已开始") -``` - -#### 注意事项 - -1. 只适用于 `@on_command` 和 `@on_message` -2. 不能与 `@rate_limit` 叠加使用 -3. `cooldown` 本质上是 `limit=1` 的 `rate_limit` - ---- - -## 能力暴露装饰器 - -### @provide_capability - -暴露插件能力给其他插件调用的装饰器。 - -#### 签名 - -```python -def provide_capability( - name: str, - *, - description: str, - input_schema: dict[str, Any] | None = None, - output_schema: dict[str, Any] | None = None, - input_model: type[BaseModel] | None = None, - output_model: type[BaseModel] | None = None, - supports_stream: bool = False, - cancelable: bool = False, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `name` | `str` | 是 | 能力名称(不能使用保留命名空间) | -| `description` | `str` | 是 | 能力描述 | -| `input_schema` | `dict \| None` | 否* | 输入 JSON Schema | -| `output_schema` | `dict \| None` | 否* | 输出 JSON Schema | -| `input_model` | `type[BaseModel] \| None` | 否* | 输入 pydantic 模型 | -| `output_model` | `type[BaseModel] \| None` | 否* | 输出 pydantic 模型 | -| `supports_stream` | `bool` | 否 | 是否支持流式输出 | -| `cancelable` | `bool` | 否 | 是否可取消 | - -*注: `input_schema` 与 `input_model` 二选一,`output_schema` 与 `output_model` 二选一 - -#### 示例 - -```python -from pydantic import BaseModel, Field - -class CalculateInput(BaseModel): - x: int = Field(description="第一个数") - y: int = Field(description="第二个数") - -class CalculateOutput(BaseModel): - result: int = Field(description="计算结果") - -@provide_capability( - "my_plugin.calculate", - description="执行加法计算", - input_model=CalculateInput, - output_model=CalculateOutput -) -async def calculate(self, payload: dict, ctx: Context): - x = payload["x"] - y = payload["y"] - return {"result": x + y} -``` - -#### 注意事项 - -1. 保留命名空间(`handler.`, `system.`, `internal.`)不能用于插件能力 -2. `input_schema` 和 `input_model` 不能同时提供 -3. `output_schema` 和 `output_model` 不能同时提供 -4. 能力名称格式建议: `插件名.功能名` - ---- - -## LLM 工具装饰器 - -### @register_llm_tool - -注册 LLM 工具装饰器,使插件函数可被 LLM 调用。 - -#### 签名 - -```python -def register_llm_tool( - name: str | None = None, - *, - description: str | None = None, - parameters_schema: dict[str, Any] | None = None, - active: bool = True, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 默认值 | 说明 | -|------|------|------|--------|------| -| `name` | `str \| None` | 否 | 函数名 | 工具名称 | -| `description` | `str \| None` | 否 | 函数文档字符串首行 | 工具描述 | -| `parameters_schema` | `dict \| None` | 否 | 自动从函数签名推断 | 参数 JSON Schema | -| `active` | `bool` | 否 | `True` | 是否激活 | - -#### 示例 - -```python -# 自动推断参数 -@register_llm_tool() -async def get_weather(self, city: str, unit: str = "celsius"): - """获取指定城市的天气信息""" - return f"{city} 的天气: 25°C" - -# 自定义 schema -@register_llm_tool( - name="search_database", - description="搜索数据库中的记录", - parameters_schema={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "搜索关键词"}, - "limit": {"type": "integer", "description": "返回结果数量", "default": 10} - }, - "required": ["query"] - }, - active=True -) -async def search_database(self, query: str, limit: int = 10): - # 实现数据库搜索逻辑 - return {"results": [...]} -``` - -#### 注意事项 - -1. 如果不提供 `name`,将使用函数名作为工具名 -2. 如果不提供 `description`,将使用函数文档字符串的第一行 -3. 如果不提供 `parameters_schema`,会自动从函数签名推断 -4. 参数推断时会跳过 `self`, `event`, `ctx`, `context` 等特殊参数 - ---- - -### @register_agent - -注册 Agent 装饰器,将类注册为 LLM Agent。 - -#### 签名 - -```python -def register_agent( - name: str, - *, - description: str = "", - tool_names: list[str] | None = None, -) -> Callable[[type[BaseAgentRunner]], type[BaseAgentRunner]] -``` - -#### 参数 - -| 参数 | 类型 | 必需 | 默认值 | 说明 | -|------|------|------|--------|------| -| `name` | `str` | 是 | - | Agent 名称 | -| `description` | `str` | 否 | `""` | Agent 描述 | -| `tool_names` | `list[str] \| None` | 否 | `None` | 可用工具名称列表 | - -#### 示例 - -```python -from astrbot_sdk.llm.agents import BaseAgentRunner -from astrbot_sdk.llm.entities import ProviderRequest - -class WeatherAgent(BaseAgentRunner): - async def run(self, ctx: Context, request: ProviderRequest) -> Any: - # 实现 agent 运行逻辑 - return "天气信息" - -class MyPlugin(Star): - @register_agent("my_agent", description="我的智能助手") - class MyAgentRunner(BaseAgentRunner): - async def run(self, ctx: Context, request: ProviderRequest) -> Any: - return "多工具处理结果" -``` - -#### 注意事项 - -1. 必须应用于 `BaseAgentRunner` 的子类 -2. `tool_names` 指定该 agent 可以使用的 LLM 工具 -3. Agent 的实际执行由 core tool loop 管理 - ---- - -## 其他装饰器 - -### @admin_only - -`@require_admin` 的别名,功能完全相同。 - -**签名**: -```python -def admin_only(func: HandlerCallable) -> HandlerCallable -``` - ---- - -### @priority - -设置 handler 执行优先级。 - -**签名**: -```python -def priority(value: int) -> Callable[[HandlerCallable], HandlerCallable] -``` - -**参数**: -- `value`: 优先级数值,越大越先执行 - -**示例**: - -```python -@on_command("high") -@priority(100) -async def high_priority(self, event: MessageEvent): - await event.reply("我优先执行") - -@on_command("low") -@priority(1) -async def low_priority(self, event: MessageEvent): - await event.reply("我后执行") -``` - ---- - -### @conversation_command - -会话命令装饰器,支持会话超时和模式控制。 - -**签名**: -```python -def conversation_command( - command: str | Sequence[str], - *, - aliases: list[str] | None = None, - description: str | None = None, - timeout: int = 60, - mode: ConversationMode = "replace", - busy_message: str | None = None, - grace_period: float = 1.0, -) -> Callable[[HandlerCallable], HandlerCallable] -``` - -**参数**: -- `command`: 命令名称 -- `aliases`: 命令别名列表 -- `description`: 命令描述 -- `timeout`: 会话超时时间(秒) -- `mode`: 会话模式(`"replace"` 或 `"reject"`) -- `busy_message`: 会话忙时的提示消息 -- `grace_period`: 宽限期(秒) - -**示例**: - -```python -@conversation_command( - "survey", - description="问卷调查", - timeout=300, - mode="replace", - busy_message="当前有进行中的问卷" -) -async def survey(self, event: MessageEvent, ctx: Context): - await event.reply("请输入您的姓名:") -``` - ---- - -## 元数据辅助函数 - -### `get_handler_meta(func)` - -获取方法的 handler 元数据。 - -**签名**: -```python -def get_handler_meta(func: HandlerCallable) -> HandlerMeta | None -``` - -**参数**: -- `func`: 要检查的方法 - -**返回**: `HandlerMeta | None` - 元数据对象,如果没有则返回 None - -**示例**: - -```python -from astrbot_sdk.decorators import get_handler_meta - -@on_command("test") -async def test_handler(self, event: MessageEvent): - pass - -meta = get_handler_meta(test_handler) -if meta: - print(f"命令: {meta.trigger.command}") -``` - ---- - -### `get_capability_meta(func)` - -获取方法的 capability 元数据。 - -**签名**: -```python -def get_capability_meta(func: HandlerCallable) -> CapabilityMeta | None -``` - -**参数**: -- `func`: 要检查的方法 - -**返回**: `CapabilityMeta | None` - 元数据对象 - ---- - -### `get_llm_tool_meta(func)` - -获取方法的 LLM 工具元数据。 - -**签名**: -```python -def get_llm_tool_meta(func: HandlerCallable) -> LLMToolMeta | None -``` - -**参数**: -- `func`: 要检查的方法 - -**返回**: `LLMToolMeta | None` - 元数据对象 - ---- - -### `get_agent_meta(obj)` - -获取 Agent 类的元数据。 - -**签名**: -```python -def get_agent_meta(obj: Any) -> AgentMeta | None -``` - -**参数**: -- `obj`: 要检查的类或对象 - -**返回**: `AgentMeta | None` - 元数据对象 - ---- - -### `append_filter_meta(func, *, specs, local_bindings)` - -追加过滤器元数据到方法。 - -**签名**: -```python -def append_filter_meta( - func: HandlerCallable, - *, - specs: list[FilterSpec] | None = None, - local_bindings: list[Any] | None = None -) -> HandlerCallable -``` - ---- - -### `set_command_route_meta(func, route)` - -设置命令路由元数据。 - -**签名**: -```python -def set_command_route_meta( - func: HandlerCallable, - route: CommandRouteSpec -) -> HandlerCallable -``` - ---- - -## 使用示例 - -### 示例 1: 基础命令 - -```python -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command - -class MyPlugin(Star): - @on_command("hello") - async def hello(self, event: MessageEvent, ctx: Context): - await event.reply(f"你好,{event.sender_name}!") - - @on_command("echo", aliases=["repeat", "say"]) - async def echo(self, event: MessageEvent, text: str): - await event.reply(f"你说: {text}") -``` - ---- - -### 示例 2: 消息匹配 - -```python -from astrbot_sdk.decorators import on_message - -class MyPlugin(Star): - @on_message(keywords=["帮助", "help"]) - async def help(self, event: MessageEvent, ctx: Context): - await event.reply("可用命令: /hello, /echo") - - @on_message(regex=r"\d{4,}") - async def number(self, event: MessageEvent, ctx: Context): - await event.reply("检测到数字!") -``` - ---- - -### 示例 3: 装饰器组合 - -```python -from astrbot_sdk.decorators import ( - on_command, require_admin, group_only, rate_limit -) - -class MyPlugin(Star): - @on_command("admin") - @require_admin - @group_only() - @rate_limit(5, 60) - async def admin_cmd(self, event: MessageEvent, ctx: Context): - await event.reply("管理员群聊命令(每分钟最多5次)") -``` - ---- - -### 示例 4: 定时任务 - -```python -from astrbot_sdk.decorators import on_schedule - -class MyPlugin(Star): - @on_schedule(interval_seconds=3600) - async def hourly_task(self, ctx: Context): - # 每小时执行 - pass - - @on_schedule(cron="0 8 * * *") - async def morning_task(self, ctx: Context): - # 每天8点执行 - await ctx.platform.send("group_123", "早上好!") -``` - ---- - -### 示例 5: LLM 工具注册 - -```python -from astrbot_sdk import Star -from astrbot_sdk.decorators import register_llm_tool - -class MyPlugin(Star): - @register_llm_tool() - async def get_time(self) -> str: - """获取当前时间""" - import time - return f"当前时间: {time.strftime('%Y-%m-%d %H:%M:%S')}" - - @register_llm_tool( - name="calculate", - description="执行计算", - parameters_schema={ - "type": "object", - "properties": { - "expression": {"type": "string", "description": "数学表达式"} - }, - "required": ["expression"] - } - ) - async def calculate(self, expression: str) -> str: - try: - result = eval(expression) - return f"结果: {result}" - except Exception as e: - return f"计算错误: {e}" -``` - ---- - -## 注意事项 - -### 1. 装饰器顺序 - -正确的装饰器顺序很重要: - -```python -@on_command("command") # 1. 事件触发装饰器 -@platforms("qq") # 2. 过滤器装饰器 -@rate_limit(5, 60) # 3. 限制器装饰器 -@require_admin # 4. 修饰器装饰器 -async def my_handler(self, event: MessageEvent, ctx: Context): - pass -``` - -### 2. 避免常见陷阱 - -**不要混用冲突的装饰器**: - -```python -# 错误示例 -@on_message(platforms=["qq"]) -@platforms("wechat") # 冲突! -async def handler(...): pass - -# 正确示例 -@on_message(platforms=["qq", "wechat"]) -async def handler(...): pass -``` - -**不要在非消息处理器使用限制器**: - -```python -# 错误示例 -@on_event("ready") -@rate_limit(5, 60) # 不支持! -async def handler(...): pass - -# 正确示例 -@on_command("cmd") -@rate_limit(5, 60) -async def handler(...): pass -``` - -### 3. 类型注解建议 - -使用类型注解提高代码可读性: - -```python -from typing import Optional - -@on_command("greet") -async def greet_handler( - self, - event: MessageEvent, - ctx: Context -) -> None: - await event.reply("Hello!") -``` - ---- - -## 相关模块 - -- **装饰器实现**: `astrbot_sdk.decorators` -- **协议描述符**: `astrbot_sdk.protocol.descriptors` -- **事件定义**: `astrbot_sdk.events` -- **LLM 实体**: `astrbot_sdk.llm.entities` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.decorators` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/errors.md b/astrbot-sdk/src/astrbot_sdk/docs/api/errors.md deleted file mode 100644 index b8ecff9a6..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/errors.md +++ /dev/null @@ -1,651 +0,0 @@ -# 错误处理 API 完整参考 - -## 概述 - -AstrBot SDK 提供了统一的错误处理机制,支持跨进程传递错误信息。所有可预期的错误都应使用 `AstrBotError` 类或其工厂方法创建。 - -**模块路径**: `astrbot_sdk.errors` - ---- - -## 目录 - -- [错误处理流程](#错误处理流程) -- [导入方式](#导入方式) -- [ErrorCodes - 错误码常量](#errorcodes---错误码常量) -- [AstrBotError - 错误类](#astrboterror---错误类) -- [使用示例](#使用示例) -- [最佳实践](#最佳实践) - ---- - -## 导入方式 - -```python -# 从主模块导入 -from astrbot_sdk import AstrBotError - -# 从 errors 模块导入 -from astrbot_sdk.errors import AstrBotError, ErrorCodes -``` - ---- - -## 错误处理流程 - -```python -# 1. 抛出错误 -raise AstrBotError.invalid_input("参数不能为空") - -# 2. 错误被捕获并序列化为 payload -# 3. 跨进程传输后反序列化 -# 4. 在 on_error 钩子中统一处理 -``` - -```python -class MyPlugin(Star): - async def on_error(self, error: AstrBotError) -> None: - if error.retryable: - # 可重试的错误 - ctx.logger.warning(f"可重试错误: {error.message}") - else: - # 不可重试的错误 - ctx.logger.error(f"错误: {error.hint or error.message}") -``` - ---- - -## ErrorCodes - 错误码常量 - -稳定的错误码常量,用于标识不同类型的错误。 - -### 定义 - -```python -class ErrorCodes: - """AstrBot v4 的稳定错误码常量。""" -``` - -### 错误码列表 - -#### 不可重试错误(retryable=False) - -| 错误码 | 说明 | 默认提示 | -|--------|------|----------| -| `UNKNOWN_ERROR` | 未知错误 | - | -| `LLM_NOT_CONFIGURED` | LLM 未配置 | - | -| `CAPABILITY_NOT_FOUND` | 能力未找到 | 请确认 AstrBot Core 是否已注册该 capability | -| `PERMISSION_DENIED` | 权限被拒绝 | - | -| `LLM_ERROR` | LLM 错误 | - | -| `INVALID_INPUT` | 输入无效 | 请检查调用参数 | -| `CANCELLED` | 调用被取消 | - | -| `PROTOCOL_VERSION_MISMATCH` | 协议版本不匹配 | 请升级 astrbot_sdk 至最新版本 | -| `PROTOCOL_ERROR` | 协议错误 | 请检查通信双方的协议实现 | -| `INTERNAL_ERROR` | 内部错误 | 请联系插件作者 | -| `RATE_LIMITED` | 速率限制 | 操作过于频繁,请稍后再试 | -| `COOLDOWN_ACTIVE` | 冷却中 | - | - -#### 可重试错误(retryable=True) - -| 错误码 | 说明 | 默认提示 | -|--------|------|----------| -| `CAPABILITY_TIMEOUT` | 能力调用超时 | - | -| `NETWORK_ERROR` | 网络错误 | 网络请求失败,请稍后重试 | -| `LLM_TEMPORARY_ERROR` | LLM 临时错误 | - | - ---- - -## AstrBotError - 错误类 - -AstrBot SDK 的标准错误类型,支持跨进程传递。 - -### 类定义 - -```python -@dataclass(slots=True) -class AstrBotError(Exception): - code: str - message: str - hint: str = "" - retryable: bool = False - docs_url: str = "" - details: dict[str, Any] | None = None -``` - -### 属性说明 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `code` | `str` | 错误码,来自 ErrorCodes 常量 | -| `message` | `str` | 错误消息,面向开发者 | -| `hint` | `str` | 用户提示,面向终端用户 | -| `retryable` | `bool` | 是否可重试 | -| `docs_url` | `str` | 文档链接 | -| `details` | `dict[str, Any] \| None` | 详细信息 | - ---- - -## 工厂方法 - -### `cancelled(message)` - -创建取消错误。 - -```python -@classmethod -def cancelled(cls, message: str = "调用被取消") -> AstrBotError -``` - -**参数**: -- `message` (`str`): 错误消息 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.cancelled("用户取消操作") -``` - ---- - -### `capability_not_found(name)` - -创建能力未找到错误。 - -```python -@classmethod -def capability_not_found(cls, name: str) -> AstrBotError -``` - -**参数**: -- `name` (`str`): 未找到的能力名称 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.capability_not_found("my_plugin.custom_capability") -``` - ---- - -### `invalid_input(message, *, hint, docs_url, details)` - -创建输入无效错误。 - -```python -@classmethod -def invalid_input( - cls, - message: str, - *, - hint: str = "请检查调用参数", - docs_url: str = "", - details: dict[str, Any] | None = None, -) -> AstrBotError -``` - -**参数**: -- `message` (`str`): 详细错误消息 -- `hint` (`str`): 用户提示,默认 "请检查调用参数" -- `docs_url` (`str`): 文档链接 -- `details` (`dict[str, Any] | None`): 详细信息 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.invalid_input( - "参数格式错误", - hint="请使用 JSON 格式", - details={"expected": "json", "received": "text"} -) -``` - ---- - -### `protocol_version_mismatch(message)` - -创建协议版本不匹配错误。 - -```python -@classmethod -def protocol_version_mismatch(cls, message: str) -> AstrBotError -``` - -**参数**: -- `message` (`str`): 详细错误消息 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.protocol_version_mismatch("SDK 版本 4.0 与 Core 版本 3.9 不兼容") -``` - ---- - -### `protocol_error(message)` - -创建协议错误。 - -```python -@classmethod -def protocol_error(cls, message: str) -> AstrBotError -``` - -**参数**: -- `message` (`str`): 详细错误消息 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.protocol_error("无效的 payload 格式") -``` - ---- - -### `internal_error(message, *, hint, docs_url, details)` - -创建内部错误。 - -```python -@classmethod -def internal_error( - cls, - message: str, - *, - hint: str = "请联系插件作者", - docs_url: str = "", - details: dict[str, Any] | None = None, -) -> AstrBotError -``` - -**参数**: -- `message` (`str`): 详细错误消息 -- `hint` (`str`): 用户提示,默认 "请联系插件作者" -- `docs_url` (`str`): 文档链接 -- `details` (`dict[str, Any] | None`): 详细信息 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -raise AstrBotError.internal_error( - "处理逻辑异常", - hint="请检查日志并联系插件作者", - details={"traceback": "..."} -) -``` - ---- - -### `network_error(message, *, hint, docs_url, details)` - -创建网络错误。 - -```python -@classmethod -def network_error( - cls, - message: str, - *, - hint: str = "网络请求失败,请稍后重试", - docs_url: str = "", - details: dict[str, Any] | None = None, -) -> AstrBotError -``` - -**参数**: -- `message` (`str`): 详细错误消息 -- `hint` (`str`): 用户提示,默认 "网络请求失败,请稍后重试" -- `docs_url` (`str`): 文档链接 -- `details` (`dict[str, Any] | None`): 详细信息 - -**返回**: `AstrBotError` 实例 - -**特性**: `retryable=True` - -**示例**: - -```python -raise AstrBotError.network_error( - "连接超时", - hint="网络不稳定,请稍后重试", - details={"url": "...", "timeout": 30} -) -``` - ---- - -### `rate_limited(*, hint, details)` - -创建速率限制错误。 - -```python -@classmethod -def rate_limited( - cls, - *, - hint: str = "操作过于频繁,请稍后再试。", - details: dict[str, Any] | None = None, -) -> AstrBotError -``` - -**参数**: -- `hint` (`str`): 用户提示,默认 "操作过于频繁,请稍后再试。" -- `details` (`dict[str, Any] | None`): 详细信息 - -**返回**: `AstrBotError` 实例 - -**特性**: `retryable=False` - -**示例**: - -```python -raise AstrBotError.rate_limited( - hint="每分钟最多调用 5 次", - details={"limit": 5, "window": 60, "remaining": 0} -) -``` - ---- - -### `cooldown_active(*, hint, details)` - -创建冷却中错误。 - -```python -@classmethod -def cooldown_active( - cls, - *, - hint: str, - details: dict[str, Any] | None = None, -) -> AstrBotError -``` - -**参数**: -- `hint` (`str`): 用户提示 -- `details` (`dict[str, Any] | None`): 详细信息 - -**返回**: `AstrBotError` 实例 - -**特性**: `retryable=False` - -**示例**: - -```python -raise AstrBotError.cooldown_active( - hint="技能冷却中,还需等待 25 秒", - details={"cooldown": 30, "remaining": 25} -) -``` - ---- - -## 实例方法 - -### `to_payload()` - -序列化为可传输的字典格式,用于跨进程传递错误信息。 - -```python -def to_payload(self) -> dict[str, object] -``` - -**返回**: `dict[str, object]` - 包含错误信息的字典 - -**返回格式**: - -```python -{ - "code": "invalid_input", - "message": "参数格式错误", - "hint": "请使用 JSON 格式", - "retryable": False, - "docs_url": "", - "details": {"expected": "json", "received": "text"} -} -``` - ---- - -### `from_payload(payload)` - -从字典反序列化错误实例。 - -```python -@classmethod -def from_payload(cls, payload: dict[str, object]) -> AstrBotError -``` - -**参数**: -- `payload` (`dict[str, object]`): 包含错误信息的字典 - -**返回**: `AstrBotError` 实例 - -**示例**: - -```python -payload = error.to_payload() -restored_error = AstrBotError.from_payload(payload) -``` - ---- - -### `__str__()` - -返回错误消息。 - -```python -def __str__(self) -> str -``` - -**返回**: `str` - `message` 属性的值 - ---- - -## 使用示例 - -### 基本错误处理 - -```python -from astrbot_sdk import AstrBotError -from astrbot_sdk.errors import ErrorCodes - -@on_command("divide") -async def divide(self, event: MessageEvent, a: int, b: int): - if b == 0: - raise AstrBotError.invalid_input( - "除数不能为零", - hint="请输入非零的除数" - ) - return event.plain_result(f"{a} / {b} = {a / b}") -``` - -### 带详细信息的错误 - -```python -@on_command("search") -async def search(self, event: MessageEvent, keyword: str): - if not keyword or len(keyword.strip()) == 0: - raise AstrBotError.invalid_input( - "搜索关键词不能为空", - hint="请输入要搜索的关键词", - details={ - "field": "keyword", - "constraint": "non_empty", - "provided": keyword - } - ) - # 执行搜索... -``` - -### 捕获和处理错误 - -```python -@on_command("risky") -async def risky_operation(self, event: MessageEvent): - try: - result = await some_network_request() - return event.plain_result(f"成功: {result}") - except AstrBotError as e: - ctx.logger.error(f"操作失败: {e.message}") - if e.retryable: - await event.reply(f"操作失败(可重试): {e.hint or e.message}") - else: - await event.reply(f"操作失败: {e.hint or e.message}") -``` - -### 在插件中处理错误 - -```python -class MyPlugin(Star): - async def on_error(self, error: AstrBotError) -> None: - """统一处理插件中的所有错误""" - if error.code == ErrorCodes.CAPABILITY_NOT_FOUND: - self.logger.error(f"能力未找到: {error.message}") - elif error.code == ErrorCodes.NETWORK_ERROR: - self.logger.warning(f"网络错误: {error.message}") - elif error.retryable: - self.logger.warning(f"可重试错误: {error.code} - {error.message}") - else: - self.logger.error(f"错误: {error.code} - {error.message}") -``` - -### 检查特定错误码 - -```python -try: - await some_capability_call() -except AstrBotError as e: - if e.code == ErrorCodes.RATE_LIMITED: - remaining = e.details.get("remaining", 0) - await event.reply(f"请求过多,请稍后再试。剩余次数: {remaining}") - elif e.code == ErrorCodes.CAPABILITY_TIMEOUT: - await event.reply("请求超时,请稍后重试") - else: - await event.reply(f"错误: {e.hint or e.message}") -``` - -### 自定义错误(使用通用构造方法) - -```python -# 使用通用构造方法创建自定义错误 -error = AstrBotError( - code="custom_error_code", - message="自定义错误消息", - hint="这是给用户的提示", - retryable=False, - details={"custom_field": "custom_value"} -) -raise error -``` - ---- - -## 最佳实践 - -### 1. 使用工厂方法而非直接构造 - -```python -# 推荐 -raise AstrBotError.invalid_input("参数错误") - -# 不推荐(除非需要自定义错误码) -raise AstrBotError( - code=ErrorCodes.INVALID_INPUT, - message="参数错误", - hint="请检查调用参数" -) -``` - -### 2. 提供用户友好的提示 - -```python -# 推荐 -raise AstrBotError.invalid_input( - "参数 'count' 必须为正整数", - hint="请输入大于 0 的数字" -) - -# 不推荐 -raise AstrBotError.invalid_input("参数错误") -``` - -### 3. 使用 details 提供调试信息 - -```python -raise AstrBotError.invalid_input( - "参数验证失败", - hint="请检查输入格式", - details={ - "field": "email", - "pattern": "^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$", - "provided": "invalid-email" - } -) -``` - -### 4. 区分可重试和不可重试错误 - -```python -# 网络错误 - 可重试 -raise AstrBotError.network_error("连接失败") - -# 参数错误 - 不可重试 -raise AstrBotError.invalid_input("参数类型错误") -``` - -### 5. 在 on_error 中集中处理 - -```python -class MyPlugin(Star): - async def on_error(self, error: AstrBotError) -> None: - # 记录所有错误 - self.logger.error(f"错误: [{error.code}] {error.message}") - - # 可重试错误记录为警告级别 - if error.retryable: - self.logger.warning(f"可重试错误,考虑实现重试逻辑") - - # 特定错误码的特殊处理 - if error.code == ErrorCodes.CAPABILITY_NOT_FOUND: - self.logger.critical("请检查 AstrBot Core 配置") -``` - -### 6. 向用户展示适当的错误信息 - -```python -try: - result = await operation() -except AstrBotError as e: - # 优先使用 hint(面向用户) - user_message = e.hint or e.message - await event.reply(user_message) - - # 记录完整的错误信息(面向开发者) - ctx.logger.error(f"操作失败: {e.code} - {e.message}", extra=e.details) -``` - ---- - -## 相关模块 - -- **事件处理**: `astrbot_sdk.events.MessageEvent` -- **上下文**: `astrbot_sdk.context.Context` -- **插件基类**: `astrbot_sdk.star.Star` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.errors` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/message_components.md b/astrbot-sdk/src/astrbot_sdk/docs/api/message_components.md deleted file mode 100644 index 3068e6989..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/message_components.md +++ /dev/null @@ -1,948 +0,0 @@ -# 消息组件 API 完整参考 - -## 概述 - -消息组件是用于构建聊天消息的各种元素。每个组件代表消息中的一种特定内容类型,可以单独使用或组合成消息链。 - -**模块路径**: `astrbot_sdk.message_components` - ---- - -## 目录 - -- [BaseMessageComponent - 基类](#basemessagecomponent---基类) -- [Plain - 纯文本组件](#plain---纯文本组件) -- [At / AtAll - @组件](#at--atall---组件) -- [Image - 图片组件](#image---图片组件) -- [Record - 语音组件](#record---语音组件) -- [Video - 视频组件](#video---视频组件) -- [File - 文件组件](#file---文件组件) -- [Reply - 回复组件](#reply---回复组件) -- [Poke - 戳一戳组件](#poke---戳一戳组件) -- [Forward - 转发组件](#forward---转发组件) -- [MessageChain - 消息链](#messagechain---消息链) -- [辅助函数](#辅助函数) - ---- - -## 导入方式 - -```python -# 从主模块导入(推荐) -from astrbot_sdk import ( - Plain, At, AtAll, Image, Record, Video, File, Reply, Poke, Forward, - MessageChain, MessageBuilder -) - -# 从子模块导入 -from astrbot_sdk.message_components import ( - Plain, At, AtAll, Image, Record, Video, File, Reply, Poke, Forward -) -from astrbot_sdk.message_result import MessageChain, MessageBuilder - -# 辅助函数 -from astrbot_sdk.message_components import ( - payload_to_component, - component_to_payload_sync, - component_to_payload, -) -``` - ---- - -## BaseMessageComponent - 基类 - -所有消息组件的基类。 - -### 类定义 - -```python -class BaseMessageComponent: - type: str = "unknown" - - def toDict(self) -> dict[str, Any]: - """同步转换为字典 payload""" - - async def to_dict(self) -> dict[str, Any]: - """异步转换为字典 payload""" -``` - ---- - -## Plain - 纯文本组件 - -最简单的消息组件,只包含文本内容。 - -### 类定义 - -```python -class Plain(BaseMessageComponent): - type = "plain" # 序列化时为 "text" - - def __init__(self, text: str, convert: bool = True, **_: Any) -> None: - self.text = text - self.convert = convert -``` - -### 构造方法 - -```python -from astrbot_sdk import Plain - -# 基本用法 -text = Plain("Hello World") - -# 不自动 strip(保留首尾空格) -text = Plain(" Hello ", convert=False) -``` - -### 序列化格式 - -```python -# toDict() 会自动 strip 文本 -{ - "type": "text", - "data": {"text": "Hello World"} -} - -# to_dict() 保留原始文本 -{ - "type": "text", - "data": {"text": " Hello "} -} -``` - -### 使用示例 - -```python -@on_command("echo") -async def echo(self, event: MessageEvent, text: str): - await event.reply_chain([Plain(f"你说: {text}")]) -``` - ---- - -## At / AtAll - @组件 - -用于在消息中提及用户。 - -### At - @某人 - -#### 类定义 - -```python -class At(BaseMessageComponent): - type = "at" - - def __init__(self, qq: int | str, name: str | None = "", **_: Any) -> None: - self.qq = qq - self.name = name or "" -``` - -#### 构造方法 - -```python -from astrbot_sdk import At - -# @ 单个用户 -at = At(123456) -at = At("123456", name="张三") -``` - -#### 序列化格式 - -```python -{ - "type": "at", - "data": {"qq": "123456"} -} -``` - ---- - -### AtAll - @全体成员 - -#### 类定义 - -```python -class AtAll(At): - def __init__(self, **_: Any) -> None: - super().__init__(qq="all") -``` - -#### 构造方法 - -```python -from astrbot_sdk import AtAll - -at_all = AtAll() -``` - -#### 序列化格式 - -```python -{ - "type": "at", - "data": {"qq": "all"} -} -``` - ---- - -### 使用示例 - -```python -from astrbot_sdk import At, AtAll, Plain - -@on_command("at_test") -async def at_test(self, event: MessageEvent): - await event.reply_chain([ - Plain("你好 "), - At(event.user_id or "123456"), - Plain("!"), - AtAll(), - Plain("所有人请注意!") - ]) -``` - ---- - -## Image - 图片组件 - -用于在消息中发送图片。 - -### 类定义 - -```python -class Image(BaseMessageComponent): - type = "image" - - def __init__(self, file: str | None, **kwargs: Any) -> None: - self.file = file or "" - self._type = kwargs.get("_type", "") - self.subType = kwargs.get("subType", 0) - self.url = kwargs.get("url", "") - self.cache = kwargs.get("cache", True) - self.id = kwargs.get("id", 40000) - self.c = kwargs.get("c", 2) - self.path = kwargs.get("path", "") - self.file_unique = kwargs.get("file_unique", "") -``` - -### 静态构造方法 - -#### `fromURL(url, **kwargs)` - -从 URL 创建图片。 - -```python -from astrbot_sdk import Image - -img = Image.fromURL("https://example.com/image.jpg") -``` - -#### `fromFileSystem(path, **kwargs)` - -从本地文件系统创建图片。 - -```python -img = Image.fromFileSystem("/path/to/image.jpg") -``` - -#### `fromBase64(base64_data, **kwargs)` - -从 Base64 数据创建图片。 - -```python -img = Image.fromBase64("iVBORw0KGgo...") -``` - -#### `fromBytes(data, **kwargs)` - -从字节数据创建图片。 - -```python -img = Image.fromBytes(b"...") -``` - -### 实例方法 - -#### `convert_to_file_path()` - -将图片转换为本地文件路径(下载或解码)。 - -```python -path = await img.convert_to_file_path() -``` - -#### `register_to_file_service()` - -将图片注册到文件服务,返回可访问 URL。 - -```python -public_url = await img.register_to_file_service() -``` - -### 支持的格式 - -```python -# URL: "https://example.com/image.jpg" -# 本地文件: "file:///absolute/path/to/image.jpg" -# Base64: "base64://iVBORw0KGgo..." -``` - -### 使用示例 - -```python -from astrbot_sdk import Image - -@on_command("cat") -async def cat(self, event: MessageEvent): - await event.reply_image("https://example.com/cat.jpg") - -@on_command("local_img") -async def local_img(self, event: MessageEvent): - await event.reply_image("file:///path/to/image.jpg") -``` - ---- - -## Record - 语音组件 - -用于在消息中发送语音/音频。 - -### 类定义 - -```python -class Record(BaseMessageComponent): - type = "record" - - def __init__(self, file: str | None, **kwargs: Any) -> None: - self.file = file or "" - self.magic = kwargs.get("magic", False) - self.url = kwargs.get("url", "") - self.cache = kwargs.get("cache", True) - self.proxy = kwargs.get("proxy", True) - self.timeout = kwargs.get("timeout", 0) - self.text = kwargs.get("text") - self.path = kwargs.get("path") -``` - -### 静态构造方法 - -#### `fromFileSystem(path, **kwargs)` - -```python -from astrbot_sdk import Record - -audio = Record.fromFileSystem("/path/to/audio.mp3") -``` - -#### `fromURL(url, **kwargs)` - -```python -audio = Record.fromURL("https://example.com/audio.mp3") -``` - -### 实例方法 - -#### `convert_to_file_path()` - -```python -path = await audio.convert_to_file_path() -``` - -#### `register_to_file_service()` - -```python -public_url = await audio.register_to_file_service() -``` - ---- - -## Video - 视频组件 - -用于在消息中发送视频。 - -### 类定义 - -```python -class Video(BaseMessageComponent): - type = "video" - - def __init__(self, file: str, **kwargs: Any) -> None: - self.file = file - self.cover = kwargs.get("cover", "") - self.c = kwargs.get("c", 2) - self.path = kwargs.get("path", "") -``` - -### 静态构造方法 - -#### `fromFileSystem(path, **kwargs)` - -```python -from astrbot_sdk import Video - -video = Video.fromFileSystem("/path/to/video.mp4") -``` - -#### `fromURL(url, **kwargs)` - -```python -video = Video.fromURL("https://example.com/video.mp4") -``` - ---- - -## File - 文件组件 - -用于在消息中发送文件附件。 - -### 类定义 - -```python -class File(BaseMessageComponent): - type = "file" - - def __init__(self, name: str, file: str = "", url: str = "") -> None: - self.name = name - self.file_ = file - self.url = url -``` - -### 属性 - -- `name` (`str`): 文件名 -- `file_` (`str`): 本地文件路径(内部使用) -- `url` (`str`): 文件 URL - -### file 属性 (getter/setter) - -```python -@property -def file(self) -> str: - return self.file_ - -@file.setter -def file(self, value: str) -> None: - if value.startswith(("http://", "https://")): - self.url = value - else: - self.file_ = value -``` - -### 构造方法 - -```python -from astrbot_sdk import File - -# URL 文件 -file1 = File(name="document.pdf", url="https://example.com/doc.pdf") - -# 本地文件 -file2 = File(name="image.jpg", file="/path/to/image.jpg") -``` - -### 实例方法 - -#### `get_file(allow_return_url=False)` - -获取文件路径或 URL。 - -```python -path = await file.get_file() - -# 优先返回 URL -path = await file.get_file(allow_return_url=True) -``` - -#### `register_to_file_service()` - -```python -public_url = await file.register_to_file_service() -``` - -### 序列化格式 - -```python -# toDict() -{ - "type": "file", - "data": { - "name": "文件名.pdf", - "file": "本地路径或URL" - } -} - -# to_dict() -{ - "type": "file", - "data": { - "name": "文件名.pdf", - "file": "优先返回URL,否则本地路径" - } -} -``` - ---- - -## Reply - 回复组件 - -用于回复某条消息。 - -### 类定义 - -```python -class Reply(BaseMessageComponent): - type = "reply" - - def __init__(self, **kwargs: Any) -> None: - self.id = kwargs.get("id", "") - self.chain = _coerce_reply_chain(kwargs.get("chain", [])) - self.sender_id = kwargs.get("sender_id", 0) - self.sender_nickname = kwargs.get("sender_nickname", "") - self.time = kwargs.get("time", 0) - self.message_str = kwargs.get("message_str", "") - self.text = kwargs.get("text", "") - self.qq = kwargs.get("qq", 0) - self.seq = kwargs.get("seq", 0) -``` - -### 构造方法 - -```python -from astrbot_sdk import Reply, Plain - -reply = Reply( - id="msg_123", - sender_id="789", - sender_nickname="张三", - chain=[Plain("被回复的消息")] -) -``` - -### 实例方法 - -#### `toDict()` / `to_dict()` - -序列化为字典。 - ---- - -## Poke - 戳一戳组件 - -用于发送戳一戳操作。 - -### 类定义 - -```python -class Poke(BaseMessageComponent): - type = "poke" - - def __init__(self, poke_type: str | int | None = None, **kwargs: Any) -> None: - self._type = str(poke_type) - self.id = kwargs.get("id") - self.qq = kwargs.get("qq", 0) -``` - -### 构造方法 - -```python -from astrbot_sdk import Poke - -poke = Poke(poke_type="126", qq="123456") -``` - ---- - -## Forward - 转发组件 - -用于转发消息。 - -### 类定义 - -```python -class Forward(BaseMessageComponent): - type = "forward" - - def __init__(self, id: str, **_: Any) -> None: - self.id = id -``` - -### 构造方法 - -```python -from astrbot_sdk import Forward - -forward = Forward(id="forward_msg_123") -``` - ---- - -## UnknownComponent - 未知组件 - -用于表示无法识别的组件类型。 - -### 类定义 - -```python -class UnknownComponent(BaseMessageComponent): - type = "unknown" - - def __init__( - self, - *, - raw_type: str = "unknown", - raw_data: dict[str, Any] | None = None, - ) -> None: - self.raw_type = raw_type - self.raw_data = raw_data or {} -``` - -### 构造方法 - -```python -from astrbot_sdk import UnknownComponent - -unknown = UnknownComponent( - raw_type="custom_type", - raw_data={"field": "value"} -) -``` - -### 说明 - -当 `payload_to_component()` 遇到无法识别的组件类型时,会返回 `UnknownComponent` 实例,保留原始数据以便调试。 - ---- - -## MessageChain - 消息链 - -用于组合多个消息组件。 - -### 类定义 - -```python -@dataclass(slots=True) -class MessageChain: - components: list[BaseMessageComponent] = field(default_factory=list) -``` - -### 构造方法 - -```python -from astrbot_sdk.message_result import MessageChain -from astrbot_sdk.message_components import Plain, At - -# 空消息链 -chain = MessageChain() - -# 带初始组件 -chain = MessageChain([Plain("Hello"), At("123456")]) -``` - -### 实例方法 - -#### `append(component)` - -追加单个组件,返回 self 支持链式调用。 - -```python -chain.append(Plain("More text")) -``` - -#### `extend(components)` - -追加多个组件。 - -```python -chain.extend([Plain("A"), Plain("B")]) -``` - -#### `to_payload()` - -转换为协议 payload。 - -```python -payload = chain.to_payload() -``` - -#### `get_plain_text(with_other_comps_mark=False)` - -提取纯文本内容。 - -```python -text = chain.get_plain_text() -``` - ---- - -## MessageBuilder - 消息构建器 - -流式构建消息链的工具类。 - -### 使用示例 - -```python -from astrbot_sdk.message_result import MessageBuilder - -chain = (MessageBuilder() - .text("Hello ") - .at("123456") - .text("!\n") - .image("https://example.com/img.jpg") - .build()) - -await event.reply_chain(chain) -``` - -### 可用方法 - -- `.text(content)` - 添加文本 -- `.at(user_id)` - 添加@用户 -- `.at_all()` - 添加@全体成员 -- `.image(url)` - 添加图片 -- `.record(url)` - 添加语音 -- `.video(url)` - 添加视频 -- `.file(name, url=...)` - 添加文件 -- `.build()` - 构建消息链 - ---- - -## 辅助函数 - -### `payload_to_component(payload)` - -将协议 payload 转换为消息组件。 - -```python -from astrbot_sdk.message_components import payload_to_component - -component = payload_to_component(payload) -``` - -### `component_to_payload_sync(component)` - -将组件同步转换为 payload。 - -```python -from astrbot_sdk.message_components import component_to_payload_sync - -payload = component_to_payload_sync(component) -``` - -### `component_to_payload(component)` - -将组件异步转换为 payload。 - -```python -from astrbot_sdk.message_components import component_to_payload - -payload = await component_to_payload(component) -``` - ---- - -### `is_message_component(value)` - -检查值是否为消息组件。 - -```python -from astrbot_sdk.message_components import is_message_component - -if is_message_component(value): - print("是消息组件") -``` - ---- - -### `payloads_to_components(payloads)` - -批量将 payload 列表转换为组件列表。 - -```python -from astrbot_sdk.message_components import payloads_to_components - -components = payloads_to_components(payload_list) -``` - ---- - -### `build_media_component_from_url(url, *, kind)` - -从 URL 构建媒体组件。 - -```python -from astrbot_sdk.message_components import build_media_component_from_url - -# 自动识别类型 -component = build_media_component_from_url("https://example.com/image.jpg") - -# 指定类型 -component = build_media_component_from_url("https://example.com/file", kind="image") -``` - ---- - -## MediaHelper - 媒体辅助类 - -提供媒体处理的静态方法。 - -### `from_url(url, *, kind)` - -从 URL 创建媒体组件。 - -**签名**: -```python -@staticmethod -async def from_url( - url: str, - *, - kind: str = "auto" -) -> BaseMessageComponent -``` - -**参数**: -- `url`: 媒体 URL -- `kind`: 媒体类型(`"auto"`, `"image"`, `"record"`, `"video"`, `"file"`) - -**返回**: 对应的媒体组件 - -**示例**: - -```python -from astrbot_sdk.message_components import MediaHelper - -# 自动识别 -img = await MediaHelper.from_url("https://example.com/photo.jpg") - -# 指定类型 -video = await MediaHelper.from_url("https://example.com/video.mp4", kind="video") -``` - ---- - -### `download(url, save_dir)` - -下载媒体文件到指定目录。 - -**签名**: -```python -@staticmethod -async def download(url: str, save_dir: Path) -> Path -``` - -**参数**: -- `url`: 媒体 URL(仅支持 http/https) -- `save_dir`: 保存目录路径 - -**返回**: `Path` - 下载后的文件路径 - -**异常**: -- `AstrBotError`: 下载失败时抛出 - -**示例**: - -```python -from pathlib import Path -from astrbot_sdk.message_components import MediaHelper - -try: - path = await MediaHelper.download( - "https://example.com/image.jpg", - Path("./downloads") - ) - print(f"下载到: {path}") -except AstrBotError as e: - print(f"下载失败: {e.message}") -``` - ---- - -## 使用示例 - -### 处理图片消息 - -```python -@on_message() -async def save_image(self, event: MessageEvent): - images = event.get_images() - if not images: - await event.reply("消息中没有图片") - return - - for img in images: - try: - path = await img.convert_to_file_path() - # 保存图片... - await event.reply(f"已保存: {path}") - except Exception as e: - await event.reply(f"保存失败: {e}") -``` - -### 检测@和群聊/私聊 - -```python -@on_command("check") -async def check(self, event: MessageEvent): - # 检查是否群聊 - if event.is_group_chat(): - await event.reply("这是群聊消息") - elif event.is_private_chat(): - await event.reply("这是私聊消息") - - # 检查@的用户 - at_users = event.get_at_users() - if at_users: - await event.reply(f"你@了: {', '.join(at_users)}") -``` - -### 返回富文本结果 - -```python -@on_command("info") -async def info(self, event: MessageEvent): - return event.chain_result([ - Plain(f"用户: {event.sender_name}\n"), - Plain(f"ID: {event.user_id}\n"), - Plain(f"平台: {event.platform}"), - ]) -``` - ---- - -## 注意事项 - -1. **序列化差异**: - - `Plain.toDict()` 会 strip 文本 - - `Plain.to_dict()` 保留原始文本 - - `File.toDict()` 和 `to_dict()` 对 file 字段处理不同 - -2. **路径格式**: - - 本地文件: `file:///absolute/path` (Windows 下特殊处理) - - URL: `http://` 或 `https://` - - Base64: `base64://` - -3. **文件下载**: - - `convert_to_file_path()` 会下载网络文件到临时目录 - - `register_to_file_service()` 需要运行时上下文 - -4. **兼容性**: - - `At` 和 `AtAll` 序列化后的 type 都是 "at" - - `Reply` 的 chain 字段在序列化时递归处理 - ---- - -## 相关模块 - -- **消息组件**: `astrbot_sdk.message_components` -- **消息链**: `astrbot_sdk.message_result.MessageChain` -- **消息构建器**: `astrbot_sdk.message_result.MessageBuilder` -- **协议描述符**: `astrbot_sdk.protocol.descriptors` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.message_components` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/message_event.md b/astrbot-sdk/src/astrbot_sdk/docs/api/message_event.md deleted file mode 100644 index a71b564f4..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/message_event.md +++ /dev/null @@ -1,1171 +0,0 @@ -# MessageEvent 类 - 消息事件对象完整参考 - -## 概述 - -`MessageEvent` 表示接收到的聊天消息事件,包含消息的所有信息(发送者、内容、组件等)和响应方法。当用户发送消息时,AstrBot 会创建一个 `MessageEvent` 实例并传递给插件的事件处理器。 - -**模块路径**: `astrbot_sdk.events.MessageEvent` - ---- - -## 类定义 - -```python -class MessageEvent: - # 基本属性 - text: str # 消息文本内容 - user_id: str | None # 发送者用户 ID - group_id: str | None # 群组 ID(私聊时为 None) - platform: str | None # 平台标识(如 "qq", "wechat") - session_id: str # 会话 ID - self_id: str # 机器人账号 ID - platform_id: str # 平台实例标识 - message_type: str # 消息类型("private" 或 "group") - sender_name: str # 发送者昵称 - raw: dict[str, Any] # 原始消息数据(协议层 payload) - context: Context | None # 运行时上下文 -``` - ---- - -## 导入方式 - -```python -# 从主模块导入(推荐) -from astrbot_sdk import MessageEvent - -# 从子模块导入 -from astrbot_sdk.events import MessageEvent - -# 常用配套导入 -from astrbot_sdk import Context # 上下文对象 -from astrbot_sdk.decorators import on_command, on_message # 装饰器 -``` - ---- - -## 基本属性 - -### 消息内容属性 - -#### `text` - -消息的纯文本内容。 - -```python -# 类型: str -# 说明: 提取消息中的纯文本部分 - -@on_message() -async def handler(self, event: MessageEvent): - print(f"收到消息: {event.text}") -``` - -**注意**: 此属性只包含文本部分,不包含图片、@等其他组件的内容。 - ---- - -### 发送者属性 - -#### `user_id` - -发送者的用户 ID。 - -```python -# 类型: str | None -# 说明: 发送者的唯一标识符 - -@on_command("whoami") -async def whoami(self, event: MessageEvent): - await event.reply(f"你的 ID 是: {event.user_id}") -``` - -#### `sender_name` - -发送者的昵称。 - -```python -# 类型: str -# 说明: 发送者的显示名称 - -@on_command("greet") -async def greet(self, event: MessageEvent): - await event.reply(f"你好,{event.sender_name}!") -``` - ---- - -### 会话属性 - -#### `session_id` - -当前会话的唯一标识符。 - -```python -# 类型: str -# 说明: 群聊时为 group_id,私聊时为 user_id - -@on_command("session") -async def session(self, event: MessageEvent): - await event.reply(f"当前会话: {event.session_id}") -``` - -#### `group_id` - -群组 ID(仅在群聊消息中有值)。 - -```python -# 类型: str | None -# 说明: 私聊时为 None - -@on_command("check_group") -async def check_group(self, event: MessageEvent): - if event.group_id: - await event.reply(f"群组 ID: {event.group_id}") - else: - await event.reply("这是私聊消息") -``` - -#### `message_type` - -消息类型。 - -```python -# 类型: str -# 说明: "private"(私聊)或 "group"(群聊) - -@on_command("type") -async def msg_type(self, event: MessageEvent): - await event.reply(f"消息类型: {event.message_type}") -``` - ---- - -### 平台属性 - -#### `platform` - -平台标识。 - -```python -# 类型: str | None -# 说明: 如 "qq", "wechat", "telegram" 等 - -@on_command("platform") -async def platform(self, event: MessageEvent): - await event.reply(f"来自平台: {event.platform}") -``` - -#### `platform_id` - -平台实例标识。 - -```python -# 类型: str -# 说明: 同一平台可能有多个实例(如多个 QQ 账号) - -@on_command("platform_id") -async def platform_id(self, event: MessageEvent): - await event.reply(f"平台实例: {event.platform_id}") -``` - -#### `self_id` - -机器人自己的 ID。 - -```python -# 类型: str -# 说明: 当前机器人账号在平台上的 ID - -@on_command("bot_id") -async def bot_id(self, event: MessageEvent): - await event.reply(f"机器人 ID: {event.self_id}") -``` - ---- - -### 原始数据属性 - -#### `raw` - -原始消息数据(协议层 payload)。 - -```python -# 类型: dict[str, Any] -# 说明: 包含完整的原始消息数据 - -@on_command("raw") -async def raw(self, event: MessageEvent): - # 访问原始数据 - raw_data = event.raw - print(f"原始数据: {raw_data}") -``` - -**注意**: 此属性包含完整的协议层数据,格式可能因平台而异。 - ---- - -## 消息组件访问方法 - -### `get_messages()` - -获取当前事件的所有 SDK 消息组件。 - -```python -def get_messages(self) -> list[BaseMessageComponent]: - """Return SDK message components for the current event.""" -``` - -**返回**: 消息组件列表 - -**示例**: - -```python -@on_command("analyze") -async def analyze(self, event: MessageEvent): - components = event.get_messages() - for comp in components: - print(f"组件类型: {comp.type}") -``` - ---- - -### `has_component(type_)` - -检查是否包含特定类型的组件。 - -```python -def has_component(self, type_: type[BaseMessageComponent]) -> bool -``` - -**参数**: -- `type_`: 组件类型(如 `Image`, `At`, `File`) - -**返回**: `bool` - 是否包含该类型组件 - -**示例**: - -```python -@on_command("has_img") -async def has_img(self, event: MessageEvent): - if event.has_component(Image): - await event.reply("消息包含图片") - else: - await event.reply("消息不包含图片") -``` - ---- - -### `get_components(type_)` - -获取特定类型的所有组件。 - -```python -def get_components(self, type_: type[BaseMessageComponent]) -> list[BaseMessageComponent] -``` - -**参数**: -- `type_`: 组件类型 - -**返回**: 匹配的组件列表 - -**示例**: - -```python -@on_command("list_at") -async def list_at(self, event: MessageEvent): - at_comps = event.get_components(At) - for at in at_comps: - await event.reply(f"@了用户: {at.qq}") -``` - ---- - -### `get_images()` - -获取所有图片组件的便捷方法。 - -```python -def get_images(self) -> list[Image] -``` - -**返回**: 图片组件列表 - -**示例**: - -```python -@on_message(keywords=["保存图片"]) -async def save_images(self, event: MessageEvent): - images = event.get_images() - if not images: - await event.reply("消息中没有图片") - return - - saved_paths = [] - for img in images: - try: - local_path = await img.convert_to_file_path() - saved_paths.append(local_path) - except Exception as e: - await event.reply(f"保存失败: {e}") - return - - await event.reply(f"已保存 {len(saved_paths)} 张图片") -``` - ---- - -### `get_files()` - -获取所有文件组件的便捷方法。 - -```python -def get_files(self) -> list[File] -``` - -**返回**: 文件组件列表 - -**示例**: - -```python -@on_message(keywords=["文件"]) -async def handle_files(self, event: MessageEvent): - files = event.get_files() - for file in files: - await event.reply(f"收到文件: {file.name}") -``` - ---- - -### `extract_plain_text()` - -提取所有 Plain 组件的文本内容。 - -```python -def extract_plain_text(self) -> str -``` - -**返回**: 纯文本内容(拼接所有 Plain 组件) - -**注意**: 这会移除所有非文本组件(图片、@等),仅拼接纯文本。 - -**示例**: - -```python -@on_command("gettext") -async def get_text(self, event: MessageEvent): - text = event.extract_plain_text() - await event.reply(f"纯文本内容: {text}") -``` - ---- - -### `get_at_users()` - -获取消息中所有被@的用户ID列表(不包括 @全体成员)。 - -```python -def get_at_users(self) -> list[str] -``` - -**返回**: 被@的用户 ID 列表 - -**示例**: - -```python -@on_command("who_at") -async def who_at(self, event: MessageEvent): - at_users = event.get_at_users() - if at_users: - await event.reply(f"你@了这些用户: {', '.join(at_users)}") - else: - await event.reply("你没有@任何人") -``` - ---- - -## 会话与平台信息方法 - -### `is_private_chat()` / `is_group_chat()` - -判断消息类型。 - -```python -def is_private_chat(self) -> bool -def is_group_chat(self) -> bool -``` - -**返回**: `bool` - 是否为对应类型 - -**示例**: - -```python -@on_command("check") -async def check(self, event: MessageEvent): - if event.is_group_chat(): - await event.reply("这是群聊消息") - # 获取群组信息 - group_info = await event.get_group() - if group_info: - await event.reply(f"群名: {group_info.get('name')}") - elif event.is_private_chat(): - await event.reply("这是私聊消息") -``` - ---- - -### `is_admin()` - -判断发送者是否有管理员权限。 - -```python -def is_admin(self) -> bool -``` - -**返回**: `bool` - 是否为管理员 - -**示例**: - -```python -@on_command("admin_check") -async def admin_check(self, event: MessageEvent): - if event.is_admin(): - await event.reply("你是管理员") - else: - await event.reply("你不是管理员") -``` - ---- - -### `get_group()` - -获取当前群组元数据(仅群聊有效)。 - -```python -async def get_group(self) -> dict[str, Any] | None -``` - -**返回**: 群组信息字典,失败返回 None - -**示例**: - -```python -@on_command("group_info") -async def group_info(self, event: MessageEvent): - if not event.is_group_chat(): - await event.reply("这不是群聊消息") - return - - group_info = await event.get_group() - if group_info: - await event.reply(f"群名: {group_info.get('name')}") -``` - ---- - -## 回复与发送方法 - -### `reply(text)` - -回复纯文本消息。 - -```python -async def reply(self, text: str) -> None -``` - -**参数**: -- `text`: 要回复的文本内容 - -**异常**: -- `RuntimeError`: 如果未绑定 reply handler - -**示例**: - -```python -@on_command("hello") -async def hello(self, event: MessageEvent): - await event.reply("Hello, World!") -``` - ---- - -### `reply_image(image_url)` - -回复图片消息。 - -```python -async def reply_image(self, image_url: str) -> None -``` - -**参数**: -- `image_url`: 图片 URL - -**支持格式**: -- URL: `https://example.com/image.jpg` -- 本地文件: `file:///absolute/path/to/image.jpg` -- Base64: `base64://iVBORw0KGgo...` - -**示例**: - -```python -@on_command("cat") -async def cat(self, event: MessageEvent): - await event.reply_image("https://example.com/cat.jpg") - -@on_command("local_img") -async def local_img(self, event: MessageEvent): - await event.reply_image("file:///path/to/local/image.jpg") -``` - ---- - -### `reply_chain(chain)` - -回复消息链(多类型消息组合)。 - -```python -async def reply_chain( - self, - chain: MessageChain | list[BaseMessageComponent] | list[dict[str, Any]] -) -> None -``` - -**参数**: -- `chain`: 消息链组件列表 - -**示例**: - -```python -from astrbot_sdk.message_components import Plain, At, Image - -@on_command("rich") -async def rich(self, event: MessageEvent): - # 方式1: 使用 MessageChain - chain = MessageChain([ - Plain("Hello "), - At("123456"), - Plain("!"), - Image.fromURL("https://example.com/img.jpg") - ]) - await event.reply_chain(chain) - - # 方式2: 直接传递组件列表 - await event.reply_chain([ - Plain("文本"), - Image.fromURL("url") - ]) -``` - ---- - -### `react(emoji)` - -发送表情反应(如果平台支持)。 - -```python -async def react(self, emoji: str) -> bool -``` - -**参数**: -- `emoji`: emoji 表情 - -**返回**: `bool` - 是否平台支持并成功发送 - -**示例**: - -```python -@on_command("react") -async def react_cmd(self, event: MessageEvent): - supported = await event.react("👍") - if not supported: - await event.reply("该平台不支持表情反应") -``` - ---- - -### `send_typing()` - -发送正在输入状态(如果平台支持)。 - -```python -async def send_typing(self) -> bool -``` - -**返回**: `bool` - 是否平台支持并成功发送 - ---- - -### `send_streaming(generator, use_fallback=False)` - -发送流式消息。 - -```python -async def send_streaming( - self, - generator, - use_fallback: bool = False -) -> bool -``` - -**参数**: -- `generator`: 异步生成器 -- `use_fallback`: 是否使用降级模式 - -**示例**: - -```python -@on_command("stream") -async def stream_cmd(self, event: MessageEvent): - async def text_gen(): - parts = ["正在", "处理", "你的", "请求", "..."] - for part in parts: - yield part - await asyncio.sleep(0.5) - - success = await event.send_streaming(text_gen()) - if not success: - await event.reply("不支持流式消息") -``` - ---- - -## 事件控制方法 - -### `stop_event()` - -标记事件为已停止,阻止后续处理器执行。 - -```python -def stop_event(self) -> None -``` - -**示例**: - -```python -@on_command("admin") -@require_admin -async def admin_cmd(self, event: MessageEvent): - await event.reply("管理员操作已执行") - event.stop_event() # 阻止后续处理器 - -@on_command("public") -async def public_cmd(self, event: MessageEvent): - # 如果事件被停止,不会执行 - await event.reply("这是公共命令") -``` - ---- - -### `continue_event()` - -清除停止标记。 - -```python -def continue_event(self) -> None -``` - ---- - -### `is_stopped()` - -检查事件是否已停止。 - -```python -def is_stopped(self) -> bool -``` - ---- - -## Extra 数据管理 - -### `set_extra(key, value)` - -存储 SDK 本地的临时事件数据。 - -```python -def set_extra(self, key: str, value: Any) -> None -``` - -**参数**: -- `key`: 键名 -- `value`: 值 - -**示例**: - -```python -# 存储数据 -event.set_extra("custom_flag", True) -event.set_extra("temp_data", {"count": 5}) -``` - -> 请求范围内的 SDK hooks 会保留 JSON-safe 的本地 extras。不可 JSON 序列化的值只在当前 handler 内可见,不会自动带到后续 hook。 - ---- - -### `get_extra(key, default)` - -读取 SDK 本地临时事件数据。 - -```python -def get_extra(self, key: str | None = None, default: Any = None) -> Any -``` - -**参数**: -- `key`: 键名,None 时返回全部 extras -- `default`: 默认值 - -**示例**: - -```python -# 读取单个值 -flag = event.get_extra("custom_flag", False) - -# 读取全部 -all_extras = event.get_extra() -``` - ---- - -### `clear_extra()` - -清除所有 extra 数据。 - -```python -def clear_extra(self) -> None -``` - ---- - -## 结果构建方法 - -### `plain_result(text)` - -创建纯文本结果对象。 - -```python -def plain_result(self, text: str) -> PlainTextResult -``` - -**示例**: - -```python -@on_command("test") -async def test(self, event: MessageEvent): - return event.plain_result("返回内容") -``` - ---- - -### `image_result(url_or_path)` - -创建包含单个图片的链结果。 - -```python -def image_result(self, url_or_path: str) -> MessageEventResult -``` - -**参数**: -- `url_or_path`: URL 或本地路径 - -**支持格式**: -- URL: `https://example.com/image.jpg` -- 本地路径: `/path/to/image.jpg` -- Base64: `base64://iVBORw0KGgo...` - -**示例**: - -```python -@on_command("avatar") -async def avatar(self, event: MessageEvent): - return event.image_result("https://example.com/avatar.jpg") -``` - ---- - -### `chain_result(chain)` - -从 SDK 组件创建链结果。 - -```python -def chain_result( - self, - chain: MessageChain | list[BaseMessageComponent] -) -> MessageEventResult -``` - -**示例**: - -```python -@on_command("info") -async def info(self, event: MessageEvent): - return event.chain_result([ - Plain(f"用户: {event.sender_name}\n"), - Plain(f"ID: {event.user_id}") - ]) -``` - ---- - -### `make_result()` - -创建空的 SDK 结果包装器。 - -```python -def make_result(self) -> MessageEventResult -``` - ---- - -## 序列化与反序列化 - -### `from_payload()` - -从协议载荷创建事件实例(类方法)。 - -**签名**: -```python -@classmethod -def from_payload( - cls, - payload: dict[str, Any], - *, - context: Context | None = None, - reply_handler: ReplyHandler | None = None -) -> MessageEvent -``` - -**参数**: -- `payload`: 协议层传递的消息数据字典 -- `context`: 运行时上下文 -- `reply_handler`: 自定义回复处理器 - -**返回**: `MessageEvent` 实例 - ---- - -### `to_payload()` - -转换为协议载荷格式。 - -**签名**: -```python -def to_payload(self) -> dict[str, Any] -``` - -**返回**: 可序列化的字典 - ---- - -## 会话引用属性 - -### `session_ref` - -获取会话引用对象。 - -**类型**: `SessionRef | None` - -**说明**: 包含会话的完整信息,用于跨平台通信。 - ---- - -### `target` - -`session_ref` 的别名。 - -**类型**: `SessionRef | None` - ---- - -### `unified_msg_origin` - -统一消息来源标识符。 - -**类型**: `str` - -**说明**: 等同于 `session_id`。 - ---- - -## LLM 相关方法 - -### `request_llm()` - -请求触发默认 LLM 链处理当前消息。 - -**签名**: -```python -async def request_llm(self) -> bool -``` - -**返回**: `bool` - 是否应该调用 LLM - -**示例**: - -```python -@on_command("ask") -async def ask(self, event: MessageEvent): - should_call = await event.request_llm() - if should_call: - await event.reply("已触发 LLM 处理") -``` - ---- - -### `should_call_llm()` - -读取当前默认 LLM 决策状态。 - -**签名**: -```python -async def should_call_llm(self) -> bool -``` - -**返回**: `bool` - 是否应该调用 LLM - -**示例**: - -```python -@on_message() -async def handle(self, event: MessageEvent): - if await event.should_call_llm(): - response = await ctx.llm.chat(event.text) - await event.reply(response) -``` - ---- - -## 结果管理方法 - -### `set_result()` - -存储请求范围的 SDK 结果到主机桥。 - -**签名**: -```python -async def set_result(self, result: MessageEventResult) -> MessageEventResult -``` - -**参数**: -- `result`: 消息事件结果对象 - -**返回**: 传入的 `result` 对象 - -**示例**: - -```python -result = event.chain_result([Plain("处理结果")]) -await event.set_result(result) -``` - ---- - -### `get_result()` - -从主机桥读取当前请求范围的 SDK 结果。 - -**签名**: -```python -async def get_result(self) -> MessageEventResult | None -``` - -**返回**: `MessageEventResult | None` - 结果对象,不存在则返回 None - ---- - -### `clear_result()` - -清除当前请求范围的 SDK 结果。 - -**签名**: -```python -async def clear_result(self) -> None -``` - ---- - -## 其他方法 - -### `get_message_outline()` - -获取规范化的消息摘要。 - -**签名**: -```python -def get_message_outline(self) -> str -``` - -**返回**: 消息摘要文本 - ---- - -### `get_sent_message_outline()` - -获取 `after_message_sent` 事件里的实际发送摘要文本。 - -**签名**: -```python -def get_sent_message_outline(self) -> str -``` - -**返回**: 机器人实际发送的摘要文本;非发送后事件通常返回空字符串 - ---- - -### `get_sent_messages()` - -获取 `after_message_sent` 事件里的实际发送消息组件。 - -**签名**: -```python -def get_sent_messages(self) -> list[BaseMessageComponent] -``` - -**返回**: 机器人实际发送的消息组件列表;非发送后事件通常返回空列表 - ---- - -### `bind_reply_handler()` - -绑定自定义回复处理器。 - -**签名**: -```python -def bind_reply_handler(self, reply_handler: ReplyHandler) -> None -``` - -**参数**: -- `reply_handler`: 回复处理函数,接收文本参数 - -**示例**: - -```python -def custom_reply(text: str): - print(f"回复: {text}") - -event.bind_reply_handler(custom_reply) -await event.reply("测试") # 会调用 custom_reply -``` - ---- - -## 完整使用示例 - -### 示例 1: 基础消息处理 - -```python -from astrbot_sdk.decorators import on_command, on_message - -@on_command("hello") -async def hello(self, event: MessageEvent, ctx: Context): - await event.reply(f"你好,{event.sender_name}!") - -@on_message(keywords=["帮助"]) -async def help(self, event: MessageEvent, ctx: Context): - await event.reply("可用命令: /hello") -``` - ---- - -### 示例 2: 处理图片消息 - -```python -@on_message(regex="^保存图片$") -async def save_image(self, event: MessageEvent): - images = event.get_images() - if not images: - await event.reply("消息中没有图片") - return - - for img in images: - try: - local_path = await img.convert_to_file_path() - # 保存图片... - await event.reply(f"已保存: {local_path}") - except Exception as e: - await event.reply(f"保存失败: {e}") -``` - ---- - -### 示例 3: 检测@和群聊/私聊 - -```python -@on_command("check") -async def check(self, event: MessageEvent): - # 检查是否群聊 - if event.is_group_chat(): - await event.reply("这是群聊消息") - elif event.is_private_chat(): - await event.reply("这是私聊消息") - - # 检查@的用户 - at_users = event.get_at_users() - if at_users: - await event.reply(f"你@了: {', '.join(at_users)}") - - # 检查是否包含图片 - if event.has_component(Image): - await event.reply("消息包含图片") -``` - ---- - -### 示例 4: 返回富文本结果 - -```python -@on_command("info") -async def info(self, event: MessageEvent): - return event.chain_result([ - Plain(f"用户: {event.sender_name}\n"), - Plain(f"ID: {event.user_id}\n"), - Plain(f"平台: {event.platform}"), - ]) -``` - ---- - -### 示例 5: 事件控制 - -```python -@on_command("admin") -@require_admin -async def admin(self, event: MessageEvent): - await event.reply("管理员操作已执行") - event.stop_event() # 阻止后续处理器 - -@on_command("public") -async def public(self, event: MessageEvent): - # 如果事件被停止,不会执行 - await event.reply("这是公共命令") -``` - ---- - -## 注意事项 - -1. **必须绑定上下文**: 某些方法(如 `reply_image`, `reply_chain`, `get_group`)需要运行时上下文,未绑定时会抛出 `RuntimeError` - -2. **私有/群聊判断**: - - `is_private_chat()` 和 `is_group_chat()` 优先使用 `message_type` 字段 - - 其次通过 `group_id` 是否为 None 判断 - -3. **Extra 数据**: `_extras` 是 SDK 本地的,不会传递到核心,适合存储插件级别的临时状态 - -4. **事件停止**: `stop_event()` 只在 SDK 层面标记,不同处理器可能有不同的行为 - -5. **消息组件解析**: `get_messages()` 返回 SDK 组件列表,`extract_plain_text()` 只提取 Plain 组件 - ---- - -## 相关模块 - -- **消息组件**: `astrbot_sdk.message_components` - 所有消息组件类 -- **消息链**: `astrbot_sdk.message_result.MessageChain` - 消息链类 -- **消息构建器**: `astrbot_sdk.message_result.MessageBuilder` - 流式消息构建器 -- **会话引用**: `astrbot_sdk.protocol.descriptors.SessionRef` - 会话引用对象 - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.events.MessageEvent` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/message_result.md b/astrbot-sdk/src/astrbot_sdk/docs/api/message_result.md deleted file mode 100644 index fa3c1cb0b..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/message_result.md +++ /dev/null @@ -1,728 +0,0 @@ -# 消息结果 API 完整参考 - -## 概述 - -消息结果是用于构建和返回消息结果的类,包括消息链容器、流式构建器和事件结果包装器。 - -**模块路径**: `astrbot_sdk.message_result` - ---- - -## 目录 - -- [EventResultType - 事件结果类型枚举](#eventresulttype---事件结果类型枚举) -- [MessageChain - 消息链](#messagechain---消息链) -- [MessageBuilder - 消息构建器](#messagebuilder---消息构建器) -- [MessageEventResult - 消息事件结果](#messageeventresult---消息事件结果) - ---- - -## 导入方式 - -```python -# 从主模块导入 -from astrbot_sdk import MessageChain, MessageBuilder, MessageEventResult - -# 从子模块导入 -from astrbot_sdk.message_result import ( - MessageChain, - MessageBuilder, - MessageEventResult, - EventResultType, -) - -# 消息组件(用于构建消息链) -from astrbot_sdk.message_components import Plain, At, Image, File -``` - ---- - -## EventResultType - 事件结果类型枚举 - -事件结果的类型枚举,定义消息结果的类型。 - -### 定义 - -```python -class EventResultType(str, Enum): - EMPTY = "empty" # 空结果 - CHAIN = "chain" # 消息链结果 - PLAIN = "plain" # 纯文本结果 -``` - -### 值说明 - -| 值 | 说明 | -|------|------| -| `EventResultType.EMPTY` | 空结果,不返回任何内容 | -| `EventResultType.CHAIN` | 消息链结果,返回一个或多个消息组件 | -| `EventResultType.PLAIN` | 纯文本结果,返回文本内容 | - ---- - -## MessageChain - 消息链 - -消息链是消息组件的容器,用于组合多个组件形成复杂的消息。 - -### 类定义 - -```python -@dataclass(slots=True) -class MessageChain: - components: list[BaseMessageComponent] = field(default_factory=list) -``` - -### 构造方法 - -#### 空消息链 - -```python -from astrbot_sdk.message_result import MessageChain - -chain = MessageChain() -``` - -#### 带初始组件 - -```python -from astrbot_sdk.message_result import MessageChain -from astrbot_sdk.message_components import Plain, At - -chain = MessageChain([ - Plain("Hello"), - At("123456") -]) -``` - -### 实例方法 - -#### `append(component)` - -追加单个组件,返回 self 支持链式调用。 - -```python -def append(self, component: BaseMessageComponent) -> MessageChain: - """追加单个组件,返回 self""" - self.components.append(component) - return self -``` - -**参数**: -- `component` (`BaseMessageComponent`): 要追加的组件 - -**返回**: `MessageChain` - self - -**示例**: - -```python -chain = MessageChain() -chain.append(Plain("Hello ")) - .append(At("123456")) - .append(Plain("!")) -``` - ---- - -#### `extend(components)` - -追加多个组件,返回 self。 - -```python -def extend(self, components: list[BaseMessageComponent]) -> MessageChain: - """追加多个组件,返回 self""" - self.components.extend(components) - return self -``` - -**参数**: -- `components` (`list[BaseMessageComponent]`): 组件列表 - -**示例**: - -```python -chain = MessageChain() -chain.extend([ - Plain("A"), - Plain("B"), - Plain("C") -]) -``` - ---- - -#### `to_payload()` - -同步转换为协议 payload。 - -```python -def to_payload(self) -> list[dict[str, Any]]: - """转换为协议 payload""" - return [component_to_payload_sync(c) for c in self.components] -``` - -**返回**: `list[dict]` - 可序列化的字典列表 - ---- - -#### `to_payload_async()` - -异步转换为协议 payload。 - -```python -async def to_payload_async(self) -> list[dict[str, Any]]: - """异步转换为协议 payload""" - return [await component_to_payload(c) for c in self.components] -``` - -**注意**: 某些组件(如 Reply)的异步序列化可能包含额外逻辑 - ---- - -#### `get_plain_text(with_other_comps_mark=False)` - -提取纯文本内容。 - -```python -def get_plain_text(self, with_other_comps_mark: bool = False) -> str: - """提取纯文本内容""" - texts: list[str] = [] - for component in self.components: - if isinstance(component, Plain): - texts.append(component.text) - elif with_other_comps_mark: - texts.append(f"[{component.__class__.__name__}]") - return " ".join(texts) -``` - -**参数**: -- `with_other_comps_mark`: 是否为非文本组件显示类型标记 - -**返回**: `str` - 纯文本内容 - -**示例**: - -```python -chain = MessageChain([ - Plain("Hello "), - At("123456"), - Plain("!") -]) - -chain.get_plain_text() # "Hello !" -chain.get_plain_text(True) # "Hello [At] !" -``` - ---- - -#### `plain_text(with_other_comps_mark=False)` - -`get_plain_text()` 的别名。 - -```python -def plain_text(self, with_other_comps_mark: bool = False) -> str: - return self.get_plain_text(with_other_comps_mark=with_other_comps_mark) -``` - ---- - -### 迭代与长度 - -```python -# 迭代 -for component in chain: - print(f"组件: {component.__class__.__name__}") - -# 长度 -len(chain) # 组件数量 -``` - ---- - -### 使用示例 - -```python -from astrbot_sdk.message_result import MessageChain -from astrbot_sdk.message_components import Plain, At, Image - -# 创建并使用 -chain = MessageChain([ - Plain("Hello "), - At("123456"), - Plain("!"), - Image.fromURL("https://example.com/img.jpg") -]) - -# 转换为 payload -payload = chain.to_payload() - -# 提取文本 -text = chain.get_plain_text() - -# 链式追加 -chain.append(Plain("More text")) -``` - ---- - -## MessageBuilder - 消息构建器 - -流式构建消息链的工具类,提供流畅的 API。 - -### 类定义 - -```python -@dataclass(slots=True) -class MessageBuilder: - components: list[BaseMessageComponent] = field(default_factory=list) -``` - -### 链式方法 - -所有方法都返回 `self`,支持链式调用。 - -#### `text(content)` - -添加文本组件。 - -```python -def text(self, content: str) -> MessageBuilder: - """添加文本组件""" - self.components.append(Plain(content, convert=False)) - return self -``` - -**示例**: - -```python -builder = MessageBuilder() -builder.text("Hello ") -``` - ---- - -#### `at(user_id)` - -添加@组件。 - -```python -def at(self, user_id: str) -> MessageBuilder: - """添加@用户""" - self.components.append(At(user_id)) - return self -``` - ---- - -#### `at_all()` - -添加@全体成员。 - -```python -def at_all(self) -> MessageBuilder: - """添加@全体成员""" - self.components.append(AtAll()) - return self -``` - ---- - -#### `image(url)` - -添加图片。 - -```python -def image(self, url: str) -> MessageBuilder: - """添加图片""" - self.components.append(Image.fromURL(url)) - return self -``` - ---- - -#### `record(url)` - -添加语音。 - -```python -def record(self, url: str) -> MessageBuilder: - """添加语音""" - self.components.append(Record.fromURL(url)) - return self -``` - ---- - -#### `video(url)` - -添加视频。 - -```python -def video(self, url: str) -> MessageBuilder: - """添加视频""" - self.components.append(Video.fromURL(url)) - return self -``` - ---- - -#### `file(name, *, file="", url="")` - -添加文件。 - -```python -def file(self, name: str, *, file: str = "", url: str = "") -> MessageBuilder: - """添加文件""" - self.components.append(File(name=name, file=file, url=url)) - return self -``` - ---- - -#### `reply(**kwargs)` - -添加回复组件。 - -```python -def reply(self, **kwargs: Any) -> MessageBuilder: - """添加回复组件""" - self.components.append(Reply(**kwargs)) - return self -``` - ---- - -#### `append(component)` - -添加任意组件。 - -```python -def append(self, component: BaseMessageComponent) -> MessageBuilder: - """添加任意组件""" - self.components.append(component) - return self -``` - ---- - -#### `extend(components)` - -添加多个组件。 - -```python -def extend(self, components: list[BaseMessageComponent]) -> MessageBuilder: - """添加多个组件""" - self.components.extend(components) - return self -``` - ---- - -#### `build()` - -构建 MessageChain。 - -```python -def build(self) -> MessageChain: - """构建消息链""" - return MessageChain(list(self.components)) -``` - -**返回**: `MessageChain` - 包含所有组件的消息链对象 - ---- - -### 完整使用示例 - -```python -from astrbot_sdk.message_result import MessageBuilder -from astrbot_sdk.message_components import Plain, At, Image - -# 链式构建 -chain = (MessageBuilder() - .text("Hello ") - .at("123456") - .text("!\n") - .image("https://example.com/img.jpg") - .build()) - -# 使用 MessageChain -chain = MessageChain([ - Plain("Hello "), - At("123456"), - Plain("!\n"), - Image.fromURL("https://example.com/img.jpg") -]) - -# 两种方式结果相同 -``` - ---- - -## MessageEventResult - 消息事件结果 - -消息事件结果的包装类,用于 handler 返回值。 - -### 类定义 - -```python -@dataclass(slots=True) -class MessageEventResult: - type: EventResultType = EventResultType.EMPTY - chain: MessageChain = field(default_factory=MessageChain) -``` - -### 构造方法 - -#### 空结果 - -```python -from astrbot_sdk.message_result import MessageEventResult, EventResultType - -result = MessageEventResult() -# 或 -result = MessageEventResult(type=EventResultType.EMPTY) -``` - ---- - -#### 纯文本结果 - -```python -result = MessageEventResult( - type=EventResultType.PLAIN, - chain=MessageChain([Plain("返回内容")]) -) -``` - ---- - -#### 消息链结果 - -```python -from astrbot_sdk.message_result import MessageEventResult, EventResultType, MessageChain -from astrbot_sdk.message_components import Plain, Image - -result = MessageEventResult( - type=EventResultType.CHAIN, - chain=MessageChain([ - Plain("文本"), - Image(url="https://example.com/a.png") - ]) -) -``` - ---- - -### 实例方法 - -#### `to_payload()` - -转换为协议 payload。 - -```python -def to_payload(self) -> dict[str, Any]: - """转换为协议 payload""" - return { - "type": self.type.value, - "chain": self.chain.to_payload(), - } -``` - -**返回格式**: - -```python -# EMPTY -{"type": "empty", "chain": []} - -# CHAIN -{ - "type": "chain", - "chain": [ - {"type": "text", "data": {"text": "内容"}}, - {"type": "image", "data": {"url": "..."}} - ] -} - -# PLAIN -{ - "type": "plain", - "chain": [{"type": "text", "data": {"text": "内容"}}] -} -``` - ---- - -#### `from_payload(payload)` - -从协议 payload 创建实例。 - -```python -@classmethod -def from_payload(cls, payload: dict[str, Any]) -> MessageEventResult: - result_type_raw = str(payload.get("type", EventResultType.EMPTY.value)) - try: - result_type = EventResultType(result_type_raw) - except ValueError: - result_type = EventResultType.EMPTY - chain_payload = payload.get("chain") - components = ( - payloads_to_components(chain_payload) - if isinstance(chain_payload, list) - else [] - ) - return cls(type=result_type, chain=MessageChain(components)) -``` - ---- - -### 使用示例 - -```python -@on_command("return_text") -async def return_text(self, event: MessageEvent): - # 返回纯文本结果 - return event.plain_result("返回内容") - -@on_command("return_image") -async def return_image(self, event: MessageEvent): - # 返回图片结果 - return event.image_result("https://example.com/image.jpg") - -@on_command("return_chain") -async def return_chain(self, event: MessageEvent): - # 返回消息链结果 - return event.chain_result([ - Plain(f"用户: {event.sender_name}"), - Plain(f"ID: {event.user_id}"), - Plain(f"平台: {event.platform}"), - ]) -``` - ---- - -## 使用场景示例 - -### 场景1: 使用 MessageBuilder 构建复杂消息 - -```python -@on_command("rich") -async def rich_message(self, event: MessageEvent): - chain = (MessageBuilder() - .text("你好 ") - .at(event.user_id or "123456") - .text("!\n\n") - .image("https://example.com/welcome.jpg") - .text("这是欢迎图片") - .build()) - - await event.reply_chain(chain) -``` - ---- - -### 场景2: 使用 MessageChain 组合组件 - -```python -@on_command("multi") -async def multi_component(self, event: MessageEvent, count: int): - components = [Plain(f"发送 {count} 条消息:\n")] - - for i in range(count): - components.append(Plain(f"{i+1}. ")) - if i < count - 1: - components.append(Plain("\n")) - - await event.reply_chain(components) -``` - ---- - -### 场景3: 返回结构化结果 - -```python -@on_command("user_info") -async def user_info(self, event: MessageEvent): - return event.chain_result([ - Plain(f"用户: {event.sender_name}\n"), - Plain(f"ID: {event.user_id}\n"), - Plain(f"平台: {event.platform}\n"), - Plain(f"消息类型: {event.message_type}\n"), - ]) -``` - ---- - -## 辅助函数 - -### `coerce_message_chain(value)` - -将多种输入格式统一转换为 MessageChain。 - -**签名**: -```python -def coerce_message_chain(value: Any) -> MessageChain | None -``` - -**参数**: -- `value`: 要转换的值,支持以下类型: - - `MessageEventResult`: 提取其中的 chain - - `MessageChain`: 直接返回 - - `BaseMessageComponent`: 包装为单元素链 - - `list[BaseMessageComponent]`: 包装为链 - -**返回**: `MessageChain | None` - 转换后的消息链,无法转换则返回 None - -**示例**: - -```python -from astrbot_sdk.message_result import coerce_message_chain, MessageChain -from astrbot_sdk.message_components import Plain, Image - -# 从 MessageEventResult 提取 -chain = coerce_message_chain(result) - -# 从 MessageChain 返回 -chain = coerce_message_chain(existing_chain) - -# 从单个组件创建 -chain = coerce_message_chain(Plain("文本")) - -# 从组件列表创建 -chain = coerce_message_chain([Plain("文本"), Image.fromURL("url")]) -``` - ---- - -## 注意事项 - -1. **MessageChain 可变性**: - - `append()` 和 `extend()` 修改原链并返回 self - - 支持链式调用 - - 注意:链式操作会修改原链 - -2. **异步序列化**: - - 大多数情况用 `to_payload()` 即可 - - 包含 `Reply` 组件时建议用 `to_payload_async()` - -3. **纯文本提取**: - - `get_plain_text()` 默认忽略非文本组件 - - 设置 `with_other_comps_mark=True` 显示类型标记 - -4. **结果类型**: - - `EMPTY`: 不返回任何内容 - - `CHAIN`: 返回一个或多个消息组件 - - `PLAIN`: 返回文本内容 - ---- - -## 相关模块 - -- **消息组件**: `astrbot_sdk.message_components` -- **事件结果**: `astrbot_sdk.events.MessageEventResult` -- **事件类型**: `astrbot_sdk.events.EventResultType` - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.message_result` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/star.md b/astrbot-sdk/src/astrbot_sdk/docs/api/star.md deleted file mode 100644 index 30a7899fb..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/star.md +++ /dev/null @@ -1,740 +0,0 @@ -# Star 类 - 插件基类完整参考 - -## 概述 - -`Star` 是 AstrBot SDK 的插件基类,所有 v4 原生插件都必须继承此类。它提供了完整的插件生命周期管理、上下文访问和能力集成。 - -**模块路径**: `astrbot_sdk.star.Star` - ---- - -## 类定义 - -```python -class Star(PluginKVStoreMixin): - """v4 原生插件基类""" - - __handlers__: tuple[str, ...] # 自动收集的处理器列表 - - # 生命周期钩子 - async def on_start(self, ctx: Any | None = None) -> None - async def on_stop(self, ctx: Any | None = None) -> None - async def initialize(self) -> None - async def terminate(self) -> None - async def on_error(self, error: Exception, event, ctx) -> None - - # 便捷属性 - @property - def context(self) -> Context | None - - # 便捷方法 - async def text_to_image(self, text: str, *, return_url: bool = True) -> str - async def html_render(self, tmpl: str, data: dict, *, return_url: bool = True) -> str - - # KV 存储方法(继承自 PluginKVStoreMixin) - async def put_kv_data(self, key: str, value: Any) -> None - async def get_kv_data(self, key: str, default: _VT) -> _VT - async def delete_kv_data(self, key: str) -> None -``` - ---- - -## 导入方式 - -```python -# 从主模块导入(推荐) -from astrbot_sdk import Star - -# 从子模块导入 -from astrbot_sdk.star import Star - -# 常用配套导入 -from astrbot_sdk import Context, MessageEvent # 上下文和事件 -from astrbot_sdk.decorators import on_command, on_message # 装饰器 -from astrbot_sdk.errors import AstrBotError # 错误处理 -``` - ---- - -## 核心属性 - -### `__handlers__` - -自动收集的事件处理器元组。 - -```python -class MyPlugin(Star): - @on_command("cmd1") - async def cmd1_handler(self, event, ctx): - pass - -# MyPlugin.__handlers__ == ("cmd1_handler",) -``` - -**说明**: 在子类创建时,`__init_subclass__()` 会自动扫描所有装饰了 `@on_command`、`@on_message` 等装饰器的方法,并将处理器名称收集到此元组中。 - -### `context` - -获取当前运行时上下文的属性。 - -```python -class MyPlugin(Star): - async def some_method(self): - ctx = self.context - if ctx: - await ctx.db.set("key", "value") -``` - -**返回**: `Context | None` - 仅在生命周期钩子和 Handler 执行期间可用 - -**注意**: 不要存储此引用,它在插件停止后会被清除 - ---- - -## 生命周期钩子 - -### 1. `on_start(ctx)` - 插件启动钩子 - -**签名**: -```python -async def on_start(self, ctx: Any | None = None) -> None -``` - -**参数**: -- `ctx`: 运行时上下文(通常为 `Context` 实例) - -**触发时机**: Worker 启动后,在开始处理事件之前调用 - -**用途**: -- 初始化数据库连接 -- 加载配置文件 -- 注册 LLM 工具 -- 启动后台任务 -- 验证外部依赖 - -**示例**: - -```python -class MyPlugin(Star): - async def on_start(self, ctx) -> None: - # 确保 initialize 被调用 - await super().on_start(ctx) - - # 获取插件数据目录 - data_dir = await ctx.get_data_dir() - - # 加载配置 - config = await ctx.metadata.get_plugin_config() - self.api_key = config.get("api_key", "") - - # 注册 LLM 工具 - await ctx.register_llm_tool( - name="search", - parameters_schema={...}, - desc="搜索信息", - func_obj=self.search_tool - ) - - # 启动后台任务 - await ctx.register_task( - self.background_sync(), - desc="后台数据同步" - ) - - ctx.logger.info(f"{ctx.plugin_id} 启动成功") -``` - -**注意事项**: -1. 始终调用 `await super().on_start(ctx)` 确保 `initialize()` 被调用 -2. 在此方法中抛出的异常会导致插件加载失败 -3. 此方法中 `ctx` 参数保证不为 `None` - ---- - -### 2. `on_stop(ctx)` - 插件停止钩子 - -**签名**: -```python -async def on_stop(self, ctx: Any | None = None) -> None -``` - -**参数**: -- `ctx`: 运行时上下文 - -**触发时机**: 插件卸载或程序关闭前调用 - -**用途**: -- 关闭数据库连接 -- 清理临时文件 -- 注销 LLM 工具 -- 保存状态数据 - -**示例**: - -```python -class MyPlugin(Star): - async def on_stop(self, ctx) -> None: - # 保存状态 - await self.put_kv_data("last_shutdown", time.time()) - - # 注销工具 - if hasattr(self, '_tool_name'): - await ctx.unregister_llm_tool(self._tool_name) - - # 确保 terminate 被调用 - await super().on_stop(ctx) - - ctx.logger.info(f"{ctx.plugin_id} 已停止") -``` - -**注意事项**: -1. 始终调用 `await super().on_stop(ctx)` 确保 `terminate()` 被调用 -2. 此方法中的异常会被捕获并记录,不会阻止插件关闭 -3. 此时可能没有活跃的事件处理,避免发送消息 - ---- - -### 3. `initialize()` - 初始化钩子 - -**签名**: -```python -async def initialize(self) -> None -``` - -**触发时机**: `on_start()` 内部自动调用 - -**用途**: -- 插件级别的初始化逻辑 -- 不依赖 Context 的初始化 - -**示例**: - -```python -class MyPlugin(Star): - async def initialize(self) -> None: - """初始化插件""" - self._cache = {} - self._counter = 0 - self.state = "ready" -``` - -**与 `on_start` 的区别**: -- `initialize()` 无 `Context` 参数,用于不依赖外部资源的初始化 -- `on_start(ctx)` 有 `Context` 参数,用于需要访问 Core 的初始化 - -**调用顺序**: -``` -插件实例化 - ↓ -initialize() ← 先调用(无 Context) - ↓ -on_start(ctx) ← 后调用(有 Context) -``` - ---- - -### 4. `terminate()` - 终止钩子 - -**签名**: -```python -async def terminate(self) -> None -``` - -**触发时机**: `on_stop()` 内部自动调用 - -**用途**: -- 插件级别的清理逻辑 -- 不依赖 Context 的清理 - -**示例**: - -```python -class MyPlugin(Star): - async def terminate(self) -> None: - """清理插件资源""" - self._cache.clear() - self.state = "stopped" -``` - -**与 `on_stop` 的区别**: -- `terminate()` 无 `Context` 参数,用于清理插件内部资源 -- `on_stop(ctx)` 有 `Context` 参数,用于清理需要与 Core 交互的资源 - -**调用顺序**: -``` -on_stop(ctx) ← 先调用(有 Context) - ↓ -terminate() ← 后调用(无 Context) - ↓ -插件卸载 -``` - ---- - -### 5. `on_error(error, event, ctx)` - 错误处理钩子 - -**签名**: -```python - async def on_error(self, error: Exception, event, ctx) -> None - - # 类方法 - @classmethod - def __astrbot_is_new_star__(cls) -> bool -``` - -**参数**: -- `error`: 捕获的异常 -- `event`: 事件对象(可能是 `MessageEvent` 或其他类型) -- `ctx`: 上下文对象 - -**触发时机**: 任何 Handler 执行抛出异常时 - -**默认行为**: -- `AstrBotError`:根据错误类型发送友好提示 -- 其他异常:发送通用错误消息 -- 记录错误日志 - -**示例**: - -```python -from astrbot_sdk.errors import AstrBotError - -class MyPlugin(Star): - async def on_error(self, error: Exception, event, ctx) -> None: - """自定义错误处理""" - - # SDK 标准错误 - if isinstance(error, AstrBotError): - lines = [] - if error.retryable: - lines.append("请求失败,请稍后重试") - elif error.hint: - lines.append(error.hint) - else: - lines.append(error.message) - - if error.docs_url: - lines.append(f"文档:{error.docs_url}") - - await event.reply("\n".join(lines)) - - # 业务逻辑错误 - elif isinstance(error, ValueError): - await event.reply(f"参数错误:{error}") - - # 网络错误 - elif isinstance(error, ConnectionError): - await event.reply("网络连接失败,请检查网络设置") - - # 未知错误 - else: - await event.reply(f"出错了:{type(error).__name__}") - - # 记录详细错误 - ctx.logger.error(f"Handler failed: {error}", exc_info=error) -``` - -**覆盖建议**: -1. 始终记录错误日志 -2. 向用户提供友好的错误提示 -3. 调用 `await super().on_error(...)` 作为后备 - ---- - -## 类方法 - -### `__astrbot_is_new_star__()` - -标识类为 v4 原生插件。 - -**签名**: -```python -@classmethod -def __astrbot_is_new_star__(cls) -> bool -``` - -**返回**: `bool` - 始终返回 `True` - -**说明**: 此方法用于运行时识别插件类型,v4 原生插件返回 `True`,旧版插件无此方法。 - ---- - -## 便捷方法 - -### `text_to_image()` - -将文本渲染为图片。 - -**签名**: -```python -async def text_to_image( - self, - text: str, - *, - return_url: bool = True -) -> str -``` - -**参数**: -- `text`: 要渲染的文本 -- `return_url`: 是否返回 URL(False 则返回本地路径) - -**返回**: 图片 URL 或路径 - -**示例**: - -```python -class MyPlugin(Star): - @on_command("text_img") - async def text_to_image_cmd(self, event: MessageEvent): - url = await self.text_to_image("Hello World") - await event.reply_image(url) -``` - -**等价于**: -```python -url = await ctx.text_to_image("Hello World") -``` - ---- - -### `html_render()` - -渲染 HTML 模板。 - -**签名**: -```python -async def html_render( - self, - tmpl: str, - data: dict, - *, - return_url: bool = True, - options: dict[str, Any] | None = None -) -> str -``` - -**参数**: -- `tmpl`: HTML 模板内容 -- `data`: 模板数据 -- `return_url`: 是否返回 URL -- `options`: 渲染选项 - -**返回**: 渲染结果 URL 或路径 - -**示例**: - -```python -class MyPlugin(Star): - @on_command("card") - async def card_cmd(self, event: MessageEvent): - url = await self.html_render( - tmpl="

{{ title }}

{{ content }}

", - data={"title": "标题", "content": "内容"} - ) - await event.reply_image(url) -``` - -**等价于**: -```python -url = await ctx.html_render(tmpl, data) -``` - ---- - -## KV 存储方法 - -这些方法继承自 `PluginKVStoreMixin`,提供简单的键值存储能力。 - -### `put_kv_data()` - -存储数据。 - -**签名**: -```python -async def put_kv_data(self, key: str, value: Any) -> None -``` - -**示例**: - -```python -await self.put_kv_data("last_run", time.time()) -``` - -### `get_kv_data()` - -获取数据。 - -**签名**: -```python -async def get_kv_data(self, key: str, default: _VT) -> _VT -``` - -**示例**: - -```python -last_run = await self.get_kv_data("last_run", 0) -``` - -### `delete_kv_data()` - -删除数据。 - -**签名**: -```python -async def delete_kv_data(self, key: str) -> None -``` - -**示例**: - -```python -await self.delete_kv_data("temp_data") -``` - ---- - -## 完整插件示例 - -```python -""" -完整的插件示例 -""" - -from astrbot_sdk import Star, Context, MessageEvent -from astrbot_sdk.decorators import on_command, on_message, provide_capability -from astrbot_sdk.errors import AstrBotError -import asyncio -import time - -class CompletePlugin(Star): - """完整功能插件""" - - async def initialize(self) -> None: - """初始化""" - self._stats = { - "start_time": time.time(), - "command_count": 0 - } - - async def on_start(self, ctx) -> None: - """启动""" - await super().on_start(ctx) - - # 加载配置 - config = await ctx.metadata.get_plugin_config() - self.greeting = config.get("greeting", "你好") - - # 注册 LLM 工具 - await ctx.register_llm_tool( - name="get_time", - parameters_schema={ - "type": "object", - "properties": {}, - "required": [] - }, - desc="获取当前时间", - func_obj=self.get_time_tool - ) - - # 启动后台任务 - await ctx.register_task( - self.background_sync(), - desc="后台数据同步" - ) - - ctx.logger.info("Plugin started") - - async def on_stop(self, ctx) -> None: - """停止""" - # 保存统计 - await self.put_kv_data("stats", self._stats) - await super().on_stop(ctx) - ctx.logger.info("Plugin stopped") - - @on_command("hello", aliases=["hi", "greet"]) - async def hello(self, event: MessageEvent, ctx: Context) -> None: - """打招呼命令""" - self._stats["command_count"] += 1 - await event.reply(f"{self.greeting},{event.sender_name}!") - - @on_command("stats") - async def stats(self, event: MessageEvent, ctx: Context) -> None: - """统计信息""" - uptime = time.time() - self._stats["start_time"] - await event.reply(f""" - 运行时间: {uptime:.0f}秒 - 命令次数: {self._stats['command_count']} - """) - - @on_message(keywords=["帮助"]) - async def help(self, event: MessageEvent, ctx: Context) -> None: - """帮助信息""" - await event.reply(""" - 可用命令: - /hello - 打招呼 - /stats - 统计信息 - /time - 当前时间 - """) - - @on_command("time") - async def time_cmd(self, event: MessageEvent, ctx: Context) -> None: - """获取时间""" - result = await self.get_time_tool() - await event.reply(result) - - async def get_time_tool(self) -> str: - """LLM 工具实现""" - return f"当前时间: {time.strftime('%Y-%m-%d %H:%M:%S')}" - - async def background_sync(self): - """后台任务""" - while True: - await asyncio.sleep(3600) - # 执行同步逻辑 - pass - - async def on_error(self, error: Exception, event, ctx) -> None: - """错误处理""" - if isinstance(error, AstrBotError): - await event.reply(error.hint or error.message) - else: - await event.reply(f"发生错误: {type(error).__name__}") - ctx.logger.error(f"Error: {error}", exc_info=error) -``` - ---- - -## plugin.yaml 配置 - -```yaml -_schema_version: 2 -name: my_plugin -author: Your Name -version: 1.0.0 -desc: 我的插件描述 -repo: https://github.com/user/repo -logo: assets/logo.png - -runtime: - python: "3.12" - -components: - - class: main:MyPlugin - -support_platforms: - - aiocqhttp - - telegram - - discord - -astrbot_version: ">=4.13.0,<5.0.0" - -config: - timeout: 30 - max_retries: 3 - api_key: "" -``` - ---- - -## 最佳实践 - -### 1. 资源初始化与清理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 创建资源 - self._session = aiohttp.ClientSession() - self._task = asyncio.create_task(self.background_task()) - - async def on_stop(self, ctx): - # 清理资源 - if hasattr(self, '_task'): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - - if hasattr(self, '_session'): - await self._session.close() -``` - -### 2. 配置管理 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - config = await ctx.metadata.get_plugin_config() - - # 提供默认值 - self.timeout = config.get("timeout", 30) - - # 验证必需配置 - if "api_key" not in config: - raise ValueError("缺少必需配置: api_key") - - self.api_key = config["api_key"] -``` - -### 3. 状态持久化 - -```python -class MyPlugin(Star): - async def on_start(self, ctx): - # 加载状态 - self.last_update = await self.get_kv_data("last_update", 0) - self.user_data = await self.get_kv_data("users", {}) - - async def on_stop(self, ctx): - # 保存状态 - await self.put_kv_data("last_update", time.time()) - await self.put_kv_data("users", self.user_data) -``` - -### 4. 错误处理 - -```python -class MyPlugin(Star): - async def on_error(self, error, event, ctx): - # 根据错误类型发送不同的提示 - if isinstance(error, ValueError): - await event.reply("参数错误") - elif isinstance(error, ConnectionError): - await event.reply("网络连接失败") - else: - # 使用默认处理 - await super().on_error(error, event, ctx) - - # 记录日志 - ctx.logger.error(f"Handler error: {error}", exc_info=error) -``` - ---- - -## 注意事项 - -1. **异步方法**: 所有生命周期钩子都是异步方法,必须使用 `async def` 声明 - -2. **super() 调用**: 在 `on_start` 和 `on_stop` 中始终调用 `await super().xxx(ctx)` 确保 `initialize`/`terminate` 被调用 - -3. **context 属性**: 仅在生命周期钩子和 Handler 执行期间可用,不要存储此引用 - -4. **异常处理**: `on_start` 中的异常会导致插件加载失败,`on_stop` 中的异常会被捕获并记录 - -5. **资源清理**: 确保在 `on_stop` 或 `terminate` 中清理所有资源(连接、任务、文件等) - ---- - -## 相关模块 - -- **装饰器**: `astrbot_sdk.decorators` - 事件处理装饰器 -- **上下文**: `astrbot_sdk.context.Context` - 运行时上下文 -- **事件**: `astrbot_sdk.events.MessageEvent` - 消息事件 -- **错误**: `astrbot_sdk.errors.AstrBotError` - SDK 错误类 - ---- - -**版本**: v4.0 -**模块**: `astrbot_sdk.star.Star` -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/types.md b/astrbot-sdk/src/astrbot_sdk/docs/api/types.md deleted file mode 100644 index ca3f28032..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/types.md +++ /dev/null @@ -1,497 +0,0 @@ -# 类型定义 API 完整参考 - -## 概述 - -本文档介绍 AstrBot SDK 中常用的类型定义,包括类型别名、泛型变量和类型注解。 - -**模块路径**: 分布在各个 SDK 模块中 - ---- - -## 目录 - -- [类型别名](#类型别名) -- [泛型变量](#泛型变量) -- [特殊类型](#特殊类型) -- [使用示例](#使用示例) - ---- - -## 导入方式 - -```python -# 类型别名 -from astrbot_sdk.context import PlatformCompatContent -from astrbot_sdk.clients.llm import ChatMessage, ChatHistoryItem, LLMResponse - -# 泛型变量(通常不需要直接导入) -from astrbot_sdk.session_waiter import _P, _ResultT, _OwnerT -from astrbot_sdk.plugin_kv import _VT - -# 通用类型 -from typing import Callable, Awaitable, Any, Sequence, Mapping - -HandlerType = Callable[..., Awaitable[Any]] -FilterType = Callable[..., Awaitable[bool]] -``` - ---- - -## 类型别名 - -### PlatformCompatContent - -平台兼容的内容类型,用于表示可以发送到平台的各种消息格式。 - -**定义位置**: `astrbot_sdk.context` - -**定义**: - -```python -from collections.abc import Sequence -from typing import Any - -PlatformCompatContent = ( - str | MessageChain | Sequence[BaseMessageComponent] | Sequence[dict[str, Any]] -) -``` - -**说明**: - -此类型别名表示可以用于平台发送方法的内容类型,支持以下四种格式: - -| 格式 | 说明 | 示例 | -|------|------|------| -| `str` | 纯文本字符串 | `"Hello World"` | -| `MessageChain` | 消息链对象 | `MessageChain([Plain("Hi")])` | -| `Sequence[BaseMessageComponent]` | 消息组件列表 | `[Plain("Hi"), At("123")]` | -| `Sequence[dict[str, Any]]` | 序列化后的字典列表 | `[{"type": "text", "data": {"text": "Hi"}}]` | - -**使用位置**: - -- `Context.send_message()` -- `Context.send_message_by_id()` -- `PlatformClient.send_by_session()` -- `StarTools.send_message()` - -**示例**: - -```python -from astrbot_sdk import Plain, Image, MessageChain - -# 纯文本 -await ctx.platform.send_by_session("session_id", "Hello") - -# 消息链 -chain = MessageChain([Plain("Hello"), Image.fromURL("...")]) -await ctx.platform.send_by_session("session_id", chain) - -# 组件列表 -await ctx.platform.send_by_session("session_id", [ - Plain("Hello"), - At("123456") -]) - -# 字典列表 -await ctx.platform.send_by_session("session_id", [ - {"type": "text", "data": {"text": "Hello"}} -]) -``` - ---- - -### ChatHistoryItem - -聊天历史项类型,用于构建对话历史。 - -**定义位置**: `astrbot_sdk.clients.llm` - -**定义**: - -```python -from collections.abc import Mapping -from typing import Any -from pydantic import BaseModel - -class ChatMessage(BaseModel): - role: str - content: str - -ChatHistoryItem = ChatMessage | Mapping[str, Any] -``` - -**说明**: - -此类型别名表示对话历史中的一项,可以是 `ChatMessage` 对象或任何字典类型的映射。 - -**支持格式**: - -| 格式 | 说明 | 示例 | -|------|------|------| -| `ChatMessage` | Pydantic 模型对象 | `ChatMessage(role="user", content="Hi")` | -| `Mapping[str, Any]` | 字典类型 | `{"role": "user", "content": "Hi"}` | - -**使用位置**: - -- `LLMClient.chat()` - `history` 参数 -- `LLMClient.chat_raw()` - `history` 参数 -- `LLMClient.stream_chat()` - `history` 参数 - -**示例**: - -```python -from astrbot_sdk.clients.llm import ChatMessage - -# 使用 ChatMessage 对象 -history = [ - ChatMessage(role="user", content="你好"), - ChatMessage(role="assistant", content="你好!"), -] - -# 使用字典 -history = [ - {"role": "user", "content": "你好"}, - {"role": "assistant", "content": "你好!"}, -] - -# 混合使用 -history = [ - ChatMessage(role="user", content="你好"), - {"role": "assistant", "content": "你好!"}, - {"role": "user", "content":今天天气怎么样?"}, -] -``` - ---- - -## 泛型变量 - -SDK 内部使用的泛型类型变量,用于类型注解。 - -### `_P` - 参数规范 - -**定义位置**: `astrbot_sdk.session_waiter` - -**定义**: - -```python -from typing import ParamSpec - -_P = ParamSpec("_P") -``` - -**说明**: - -用于捕获可调用对象的参数签名,主要在装饰器中使用。 - ---- - -### `_ResultT` - 结果类型 - -**定义位置**: `astrbot_sdk.session_waiter` - -**定义**: - -```python -from typing import TypeVar - -_ResultT = TypeVar("_ResultT") -``` - -**说明**: - -表示异步函数的返回结果类型。 - ---- - -### `_OwnerT` - 所有者类型 - -**定义位置**: `astrbot_sdk.session_waiter` - -**定义**: - -```python -_OwnerT = TypeVar("_OwnerT") -``` - -**说明**: - -表示类的所有者类型(通常是 `Star` 子类)。 - ---- - -### `_VT` - 值类型 - -**定义位置**: `astrbot_sdk.plugin_kv` - -**定义**: - -```python -_VT = TypeVar("_VT") -``` - -**说明**: - -用于 KV 存储中默认值的类型。 - -**使用位置**: - -- `PluginKVStoreMixin.get_kv_data()` - `default` 参数的类型注解 - -**示例**: - -```python -# default 参数的类型会根据传入的值自动推断 -value = await self.get_kv_data("key", default="default") # _VT 推断为 str -count = await self.get_kv_data("count", default=0) # _VT 推断为 int -``` - ---- - -## 特殊类型 - -### HandlerType - -事件处理器函数类型。 - -**定义**: - -```python -from typing import Callable, Awaitable, Any - -HandlerType = Callable[..., Awaitable[Any]] -``` - -**说明**: - -表示事件处理器的函数签名,接受任意参数并返回异步结果。 - -**特征**: -- 可变参数 (`...`) -- 异步返回 (`Awaitable[Any]`) - -**示例**: - -```python -async def my_handler(event: MessageEvent, ctx: Context) -> None: - pass - -# 符合 HandlerType 类型 -``` - ---- - -### FilterType - -过滤器函数类型。 - -**定义**: - -```python -FilterType = Callable[..., Awaitable[bool]] -``` - -**说明**: - -表示过滤器函数的类型,返回布尔值。 - -**特征**: -- 可变参数 (`...`) -- 异步返回布尔值 (`Awaitable[bool]`) - -**示例**: - -```python -async def my_filter(event: MessageEvent, ctx: Context) -> bool: - return event.platform == "qq" - -# 符合 FilterType 类型 -``` - ---- - -## Pydantic 模型类型 - -### ChatMessage - -聊天消息模型,用于构建对话历史。 - -**定义位置**: `astrbot_sdk.clients.llm` - -**定义**: - -```python -from pydantic import BaseModel - -class ChatMessage(BaseModel): - """聊天消息模型。""" - role: str - content: str -``` - -**属性**: - -| 属性 | 类型 | 说明 | -|------|------|------| -| `role` | `str` | 消息角色,如 `"user"`, `"assistant"`, `"system"` | -| `content` | `str` | 消息内容 | - -**示例**: - -```python -from astrbot_sdk.clients.llm import ChatMessage - -# 系统提示 -system_msg = ChatMessage( - role="system", - content="你是一个友好的助手" -) - -# 用户消息 -user_msg = ChatMessage( - role="user", - content="你好" -) - -# 助手回复 -assistant_msg = ChatMessage( - role="assistant", - content="你好!有什么可以帮助你的?" -) -``` - ---- - -### LLMResponse - -LLM 响应模型,包含完整的响应信息。 - -**定义位置**: `astrbot_sdk.clients.llm` - -**定义**: - -```python -from pydantic import BaseModel, Field - -class LLMResponse(BaseModel): - """LLM 响应模型。""" - text: str - usage: dict[str, Any] | None = None - finish_reason: str | None = None - tool_calls: list[dict[str, Any]] = Field(default_factory=list) - role: str | None = None - reasoning_content: str | None = None - reasoning_signature: str | None = None -``` - -**属性**: - -| 属性 | 类型 | 说明 | -|------|------|------| -| `text` | `str` | 生成的文本内容 | -| `usage` | `dict[str, Any] \| None` | Token 使用统计 | -| `finish_reason` | `str \| None` | 结束原因(`"stop"`, `"length"`, `"tool_calls"`) | -| `tool_calls` | `list[dict[str, Any]]` | 工具调用列表 | -| `role` | `str \| None` | 响应角色 | -| `reasoning_content` | `str \| None` | 推理内容(用于推理模型) | -| `reasoning_signature` | `str \| None` | 推理签名 | - -**示例**: - -```python -from astrbot_sdk.clients.llm import LLMResponse - -response = await ctx.llm.chat_raw("写一首诗") - -print(f"生成内容: {response.text}") -print(f"Token 使用: {response.usage}") -print(f"结束原因: {response.finish_reason}") - -if response.usage: - print(f"提示词 Token: {response.usage.get('prompt_tokens')}") - print(f"完成 Token: {response.usage.get('completion_tokens')}") -``` - ---- - -## 使用示例 - -### 类型注解在函数签名中的使用 - -```python -from typing import Sequence, Mapping, Any -from astrbot_sdk.clients.llm import ChatMessage, ChatHistoryItem -from astrbot_sdk import MessageChain, BaseMessageComponent, PlatformCompatContent - -# 使用 ChatHistoryItem -async def chat_with_history( - prompt: str, - history: Sequence[ChatHistoryItem] | None = None -) -> str: - """与 LLM 聊天的函数。""" - pass - -# 使用 PlatformCompatContent -async def send_content( - session: str, - content: PlatformCompatContent -) -> dict[str, Any]: - """发送内容的函数。""" - pass -``` - -### 类型检查和类型守卫 - -```python -from collections.abc import Mapping, Sequence -from astrbot_sdk.clients.llm import ChatMessage, ChatHistoryItem - -def normalize_history_item(item: ChatHistoryItem) -> dict[str, Any]: - """将聊天历史项规范化为字典。""" - if isinstance(item, ChatMessage): - return item.model_dump() - if isinstance(item, Mapping): - return dict(item) - raise TypeError("无效的聊天历史项类型") - -# 使用 -history: Sequence[ChatHistoryItem] = [ - ChatMessage(role="user", content="Hi"), - {"role": "assistant", "content": "Hello"}, -] - -normalized = [normalize_history_item(item) for item in history] -``` - -### 泛型函数 - -```python -from typing import TypeVar, Generic - -T = TypeVar("T") - -class Container(Generic[T]): - def __init__(self, value: T) -> None: - self.value = value - - def get(self) -> T: - return self.value - -# 使用 -int_container: Container[int] = Container(42) -str_container: Container[str] = Container("hello") -``` - ---- - -## 相关模块 - -- **LLM 客户端**: `astrbot_sdk.clients.LLMClient` -- **消息组件**: `astrbot_sdk.message_components` -- **消息链**: `astrbot_sdk.message_result.MessageChain` -- **上下文**: `astrbot_sdk.context.Context` - ---- - -**版本**: v4.0 -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/docs/api/utils.md b/astrbot-sdk/src/astrbot_sdk/docs/api/utils.md deleted file mode 100644 index 62a5bbc08..000000000 --- a/astrbot-sdk/src/astrbot_sdk/docs/api/utils.md +++ /dev/null @@ -1,1087 +0,0 @@ -# 工具与辅助类 API 完整参考 - -## 概述 - -本文档介绍 AstrBot SDK 中常用的工具类和辅助类型,包括取消令牌、会话管理、命令组织、参数解析等功能。 - -**模块路径**: -- `astrbot_sdk.context.CancelToken` -- `astrbot_sdk.message_session.MessageSession` -- `astrbot_sdk.types.GreedyStr` -- `astrbot_sdk.commands` -- `astrbot_sdk.schedule.ScheduleContext` -- `astrbot_sdk.session_waiter` -- `astrbot_sdk.star_tools.StarTools` -- `astrbot_sdk.plugin_kv.PluginKVStoreMixin` - ---- - -## 目录 - -- [CancelToken - 取消令牌](#canceltoken---取消令牌) -- [MessageSession - 消息会话](#messagesession---消息会话) -- [GreedyStr - 贪婪字符串](#greedystr---贪婪字符串) -- [CommandGroup - 命令组](#commandgroup---命令组) -- [ScheduleContext - 调度上下文](#schedulecontext---调度上下文) -- [SessionController - 会话控制器](#sessioncontroller---会话控制器) -- [session_waiter - 会话等待装饰器](#session_waiter---会话等待装饰器) -- [StarTools - Star 工具类](#startools---star-工具类) -- [PluginKVStoreMixin - KV 存储混入](#pluginkvstoremixin---kv-存储混入) - ---- - -## 导入方式 - -```python -# 从主模块导入 -from astrbot_sdk import ( - CancelToken, - MessageSession, - GreedyStr, - ScheduleContext, - SessionController, - session_waiter, - StarTools, - PluginKVStoreMixin, -) - -# 从子模块导入 -from astrbot_sdk.context import CancelToken -from astrbot_sdk.message_session import MessageSession -from astrbot_sdk.types import GreedyStr -from astrbot_sdk.commands import CommandGroup, command_group, print_cmd_tree -from astrbot_sdk.schedule import ScheduleContext -from astrbot_sdk.session_waiter import SessionController, session_waiter -from astrbot_sdk.star_tools import StarTools -from astrbot_sdk.plugin_kv import PluginKVStoreMixin -``` - ---- - -## CancelToken - 取消令牌 - -请求取消令牌,用于协调长时间运行操作的取消。 - -### 类定义 - -```python -@dataclass(slots=True) -class CancelToken: - _cancelled: asyncio.Event -``` - -### 构造方法 - -```python -from astrbot_sdk import CancelToken - -token = CancelToken() -``` - -### 实例方法 - -#### `cancel()` - -触发取消信号。 - -```python -def cancel(self) -> None: - """触发取消信号。""" -``` - -**示例**: - -```python -token.cancel() -``` - ---- - -#### `cancelled` 属性 - -检查是否已被取消。 - -```python -@property -def cancelled(self) -> bool: - """检查是否已被取消。""" -``` - -**示例**: - -```python -if token.cancelled: - print("操作已取消") -``` - ---- - -#### `wait()` - -等待取消信号。 - -```python -async def wait(self) -> None: - """等待取消信号。""" -``` - -**示例**: - -```python -await token.wait() -``` - ---- - -#### `raise_if_cancelled()` - -如果已取消则抛出 `CancelledError`。 - -```python -def raise_if_cancelled(self) -> None: - """如果已取消则抛出 CancelledError。""" -``` - -**异常**: -- `asyncio.CancelledError`: 如果令牌已被取消 - -**示例**: - -```python -async def long_operation(ctx: Context): - for item in large_list: - ctx.cancel_token.raise_if_cancelled() - await process(item) -``` - ---- - -## MessageSession - 消息会话 - -统一表示消息会话标识符,格式为 `platform_id:message_type:session_id`。 - -### 类定义 - -```python -@dataclass(slots=True) -class MessageSession: - platform_id: str - message_type: str - session_id: str -``` - -### 属性 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `platform_id` | `str` | 平台实例 ID | -| `message_type` | `str` | 消息类型(`group` 或 `private`) | -| `session_id` | `str` | 会话 ID | - -### 类方法 - -#### `from_str(session)` - -从字符串解析会话。 - -```python -@classmethod -def from_str(cls, session: str) -> MessageSession: - platform_id, message_type, session_id = str(session).split(":", 2) - return cls( - platform_id=platform_id, - message_type=message_type, - session_id=session_id, - ) -``` - -**参数**: -- `session` (`str`): 会话字符串,格式为 `platform_id:message_type:session_id` - -**返回**: `MessageSession` 实例 - -**示例**: - -```python -from astrbot_sdk import MessageSession - -# 从字符串创建 -session = MessageSession.from_str("qq:group:123456") - -# 直接创建 -session = MessageSession( - platform_id="qq", - message_type="group", - session_id="123456" -) - -# 转换为字符串 -str(session) # "qq:group:123456" -``` - ---- - -## GreedyStr - 贪婪字符串 - -用于标记"贪婪字符串"参数,在命令解析时将剩余所有文本作为一个整体参数。 - -### 类定义 - -```python -class GreedyStr(str): - """Consume the remaining command text as one argument.""" -``` - -### 使用场景 - -当命令参数包含空格时,普通解析会将空格后的内容作为下一个参数,而 `GreedyStr` 会捕获剩余所有文本。 - -**示例**: - -```python -from astrbot_sdk import GreedyStr -from astrbot_sdk.decorators import on_command - -@on_command("echo") -async def echo(self, event: MessageEvent, text: GreedyStr): - # 用户输入: /echo hello world this is a test - # text = "hello world this is a test" - await event.reply(text) - -@on_command("say") -async def say(self, event: MessageEvent, name: str, message: GreedyStr): - # 用户输入: /say Alice Hello World - # name = "Alice" - # message = "Hello World" - await event.reply(f"{name} 说: {message}") -``` - ---- - -## CommandGroup - 命令组 - -用于组织具有层级关系的命令,支持命令别名和自动展开。 - -### 类定义 - -```python -class CommandGroup: - def __init__( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, - parent: CommandGroup | None = None, - ) -> None: -``` - -### 构造方法 - -```python -from astrbot_sdk import CommandGroup, command_group - -# 使用函数创建 -admin = command_group("admin", description="管理命令") - -# 使用类创建 -config = CommandGroup("config", description="配置命令") -``` - -**参数**: -- `name` (`str`): 组名称 -- `aliases` (`list[str] | None`): 别名列表 -- `description` (`str | None`): 描述信息 -- `parent` (`CommandGroup | None`): 父组 - -### 实例方法 - -#### `group(name, *, aliases, description)` - -创建子命令组。 - -```python -def group( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> CommandGroup: -``` - -**示例**: - -```python -admin = command_group("admin") -user = admin.group("user", description="用户管理") -config = admin.group("config", description="配置管理") -``` - ---- - -#### `command(name, *, aliases, description)` - -创建命令装饰器。 - -```python -def command( - self, - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, -): -``` - -**返回**: 装饰器函数 - -**示例**: - -```python -admin = command_group("admin") - -@admin.command("add", description="添加用户") -async def admin_add_user(self, event: MessageEvent, user_id: str): - await event.reply(f"添加用户: {user_id}") - -@admin.command("remove", aliases=["del"], description="删除用户") -async def admin_remove_user(self, event: MessageEvent, user_id: str): - await event.reply(f"删除用户: {user_id}") -``` - ---- - -#### `path` 属性 - -获取命令组的完整路径。 - -```python -@property -def path(self) -> list[str]: - if self.parent is None: - return [self.name] - return [*self.parent.path, self.name] -``` - -**示例**: - -```python -admin = command_group("admin") -user = admin.group("user") - -user.path # ["admin", "user"] -``` - ---- - -#### `print_cmd_tree()` - -打印命令树结构。 - -```python -def print_cmd_tree(self) -> str: - lines: list[str] = [] - self._append_tree_lines(lines, indent=0) - return "\n".join(lines) -``` - -**返回**: `str` - 命令树字符串 - -**示例**: - -```python -admin = command_group("admin") - -@admin.command("add") -async def admin_add(...): pass - -@admin.command("remove") -async def admin_remove(...): pass - -print(admin.print_cmd_tree()) -# 输出: -# admin -# - add -# - remove -``` - ---- - -### 函数 - -#### `command_group(name, *, aliases, description)` - -创建命令组实例。 - -```python -def command_group( - name: str, - *, - aliases: list[str] | None = None, - description: str | None = None, -) -> CommandGroup: - return CommandGroup( - name, - aliases=aliases, - description=description, - ) -``` - ---- - -#### `print_cmd_tree(group)` - -获取命令树字符串。 - -```python -def print_cmd_tree(group: CommandGroup) -> str: - return group.print_cmd_tree() -``` - -**示例**: - -```python -from astrbot_sdk import command_group, print_cmd_tree - -admin = command_group("admin", description="管理命令") - -@admin.command("user") -async def admin_user(...): pass - -@admin.command("setting") -async def admin_setting(...): pass - -# 获取命令树 -tree = print_cmd_tree(admin) -await event.reply(f"```\n{tree}\n```") -``` - ---- - -### 使用示例 - -#### 基本命令组 - -```python -from astrbot_sdk import Star, command_group -from astrbot_sdk.decorators import on_command -from astrbot_sdk.events import MessageEvent - -class MyPlugin(Star): - # 创建命令组 - admin = command_group("admin", description="管理命令") - - @admin.command("add", description="添加用户") - async def admin_add(self, event: MessageEvent, user_id: str): - await event.reply(f"添加用户: {user_id}") - - @admin.command("remove", aliases=["del"], description="删除用户") - async def admin_remove(self, event: MessageEvent, user_id: str): - await event.reply(f"删除用户: {user_id}") -``` - -#### 嵌套命令组 - -```python -# 创建嵌套结构 -admin = command_group("admin") -user = admin.group("user", description="用户管理") -config = admin.group("config", description="配置管理") - -@user.command("add") -async def admin_user_add(self, event: MessageEvent, user_id: str): - await event.reply(f"添加用户: {user_id}") - -@user.command("remove") -async def admin_user_remove(self, event: MessageEvent, user_id: str): - await event.reply(f"删除用户: {user_id}") - -@config.command("get") -async def admin_config_get(self, event: MessageEvent, key: str): - await event.reply(f"获取配置: {key}") - -@config.command("set") -async def admin_config_set(self, event: MessageEvent, key: str, value: str): - await event.reply(f"设置配置: {key} = {value}") -``` - -#### 使用类组织命令 - -```python -from astrbot_sdk import Star, CommandGroup - -class AdminCommands: - group = CommandGroup("admin", description="管理命令") - - @group.command("add", description="添加用户") - async def add_user(self, event, user_id: str): - await event.reply(f"添加用户: {user_id}") - - @group.command("remove", description="删除用户") - async def remove_user(self, event, user_id: str): - await event.reply(f"删除用户: {user_id}") -``` - ---- - -## ScheduleContext - 调度上下文 - -定时任务的上下文信息,包含调度任务的详细信息。 - -### 类定义 - -```python -@dataclass(slots=True) -class ScheduleContext: - schedule_id: str - plugin_id: str - handler_id: str - trigger_kind: str - cron: str | None = None - interval_seconds: int | None = None - scheduled_at: str | None = None -``` - -### 属性 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `schedule_id` | `str` | 调度任务唯一标识 | -| `plugin_id` | `str` | 所属插件 ID | -| `handler_id` | `str` | 对应 handler 的标识 | -| `trigger_kind` | `str` | 触发类型(`cron` / `interval` / `once`) | -| `cron` | `str \| None` | cron 表达式(仅 cron 类型) | -| `interval_seconds` | `int \| None` | 间隔秒数(仅 interval 类型) | -| `scheduled_at` | `str \| None` | 计划执行时间(仅 once 类型) | - -### 使用示例 - -```python -from astrbot_sdk.decorators import on_schedule -from astrbot_sdk import ScheduleContext - -class MyPlugin(Star): - @on_schedule(cron="0 8 * * *") # 每天 8:00 - async def morning_greeting(self, ctx: ScheduleContext): - # ctx.schedule_id: 任务 ID - # ctx.trigger_kind: "cron" - # ctx.cron: "0 8 * * *" - await self.send_message("群号", "早上好!") - - @on_schedule(interval_seconds=3600) # 每小时 - async def hourly_check(self, ctx: ScheduleContext): - # ctx.trigger_kind: "interval" - # ctx.interval_seconds: 3600 - pass -``` - ---- - -## SessionController - 会话控制器 - -控制会话生命周期,支持超时管理、会话保持、历史记录。 - -### 类定义 - -```python -@dataclass(slots=True) -class SessionController: - future: asyncio.Future[Any] = field(default_factory=asyncio.Future) - current_event: asyncio.Event | None = None - ts: float | None = None - timeout: float | None = None - history_chains: list[list[dict[str, Any]]] = field(default_factory=list) -``` - -### 属性 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `future` | `asyncio.Future` | 会话结果 Future | -| `current_event` | `asyncio.Event \| None` | 当前事件 | -| `ts` | `float \| None` | 时间戳 | -| `timeout` | `float \| None` | 超时时间(秒) | -| `history_chains` | `list[list[dict]]` | 历史消息链 | - -### 实例方法 - -#### `stop(error)` - -停止会话。 - -```python -def stop(self, error: Exception | None = None) -> None: - if self.future.done(): - return - if error is not None: - self.future.set_exception(error) - else: - self.future.set_result(None) -``` - -**参数**: -- `error` (`Exception | None`): 可选的错误对象 - ---- - -#### `keep(timeout, reset_timeout)` - -延长会话超时时间。 - -```python -def keep(self, timeout: float = 0, reset_timeout: bool = False) -> None: - new_ts = time.time() - if reset_timeout: - if timeout <= 0: - self.stop() - return - else: - assert self.timeout is not None - assert self.ts is not None - left_timeout = self.timeout - (new_ts - self.ts) - timeout = left_timeout + timeout - if timeout <= 0: - self.stop() - return - - if self.current_event and not self.current_event.is_set(): - self.current_event.set() - - current_event = asyncio.Event() - self.current_event = current_event - self.ts = new_ts - self.timeout = timeout - asyncio.create_task(self._holding(current_event, timeout)) -``` - -**参数**: -- `timeout` (`float`): 延长的超时时间(秒) -- `reset_timeout` (`bool`): 是否重置超时时间 - ---- - -#### `get_history_chains()` - -获取历史消息链。 - -```python -def get_history_chains(self) -> list[list[dict[str, Any]]]: - return list(self.history_chains) -``` - -**返回**: `list[list[dict]]` - 历史消息链的副本 - ---- - -## session_waiter - 会话等待装饰器 - -将普通 handler 转换为会话式 handler,用于构建多轮对话流程。 - -### 函数签名 - -```python -def session_waiter( - timeout: int = 30, - *, - record_history_chains: bool = False, -) -> _SessionWaiterDecorator: -``` - -### 参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `timeout` | `int` | `30` | 会话超时时间(秒) | -| `record_history_chains` | `bool` | `False` | 是否记录历史消息链 | - -### 使用示例 - -#### 推荐启动方式 - -`@session_waiter` 定义的是“后续消息到达时如何处理”,推荐从当前 handler -里通过 `Context.register_task()` 把 waiter 挂到后台任务中。这样首条消息的 -dispatch 会立刻结束,不会因为等待下一条消息而卡住。 - -#### 基本使用 - -```python -from astrbot_sdk import Context, session_waiter, SessionController -from astrbot_sdk.events import MessageEvent -from astrbot_sdk import Star, on_command - -class MyPlugin(Star): - @session_waiter(timeout=300) - async def collect_username( - self, - controller: SessionController, - event: MessageEvent, - ) -> None: - await event.reply(f"已记录用户名: {event.text}") - controller.stop() - - @on_command("bind") - async def bind(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("请输入用户名:") - await ctx.register_task( - self.collect_username(event), - "waiter:collect_username", - ) -``` - -#### 多轮对话 - -```python -class SurveyPlugin(Star): - @session_waiter(timeout=600, record_history_chains=True) - async def survey(self, controller: SessionController, event: MessageEvent) -> None: - history = controller.get_history_chains() - - if len(history) == 1: - await event.reply(f"收到姓名: {event.text}") - await event.reply("请输入您的年龄:") - controller.keep(timeout=300) - return - - await event.reply(f"收到年龄: {event.text}") - controller.stop() - - @on_command("survey") - async def start_survey(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("请输入您的姓名:") - await ctx.register_task(self.survey(event), "waiter:survey") -``` - -#### 直接 await 的语义 - -```python -@on_command("debug-blocking") -async def debug_blocking(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("下一条消息会在当前 dispatch 中继续处理") - await self.collect_username(event) # 会保持当前 dispatch 挂起 -``` - -上面这种直接 `await` 仍然保留现有语义,但它会一直阻塞到下一条消息到达 -或超时。常规插件逻辑推荐使用 `await ctx.register_task(waiter(...), "...")`。 - ---- - -## StarTools - Star 工具类 - -提供类方法访问运行时上下文能力,只在生命周期、handler 和已注册的 LLM 工具执行期间可用。 - -### 类定义 - -```python -class StarTools: - """Star 工具类,提供类方法访问运行时上下文能力。""" -``` - -### 类方法 - -#### `activate_llm_tool(name)` - -激活 LLM 工具。 - -```python -@classmethod -async def activate_llm_tool(cls, name: str) -> bool: - return await cls._require_context().activate_llm_tool(name) -``` - -**参数**: -- `name` (`str`): 工具名称 - -**返回**: `bool` - 是否成功激活 - ---- - -#### `deactivate_llm_tool(name)` - -停用 LLM 工具。 - -```python -@classmethod -async def deactivate_llm_tool(cls, name: str) -> bool: - return await cls._require_context().deactivate_llm_tool(name) -``` - -**参数**: -- `name` (`str`): 工具名称 - -**返回**: `bool` - 是否成功停用 - ---- - -#### `send_message(session, content)` - -发送消息。 - -```python -@classmethod -async def send_message( - cls, - session: str | MessageSession, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), -) -> dict[str, Any]: - return await cls._require_context().send_message(session, content) -``` - -**参数**: -- `session` (`str | MessageSession`): 目标会话 -- `content`: 消息内容 - -**返回**: `dict[str, Any]` - 发送结果 - ---- - -#### `send_message_by_id(type, id, content, *, platform)` - -通过 ID 发送消息。 - -```python -@classmethod -async def send_message_by_id( - cls, - type: str, - id: str, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - *, - platform: str, -) -> dict[str, Any]: - return await cls._require_context().send_message_by_id( - type, - id, - content, - platform=platform, - ) -``` - -**参数**: -- `type` (`str`): 消息类型(`group` 或 `private`) -- `id` (`str`): 目标 ID -- `content`: 消息内容 -- `platform` (`str`): 平台标识 - -**返回**: `dict[str, Any]` - 发送结果 - ---- - -#### `register_llm_tool(name, parameters_schema, desc, func_obj, *, active)` - -注册 LLM 工具。 - -```python -@classmethod -async def register_llm_tool( - cls, - name: str, - parameters_schema: dict[str, Any], - desc: str, - func_obj: Callable[..., Awaitable[Any]] | Callable[..., Any], - *, - active: bool = True, -) -> list[str]: - return await cls._require_context().register_llm_tool( - name, - parameters_schema, - desc, - func_obj, - active=active, - ) -``` - -**参数**: -- `name` (`str`): 工具名称 -- `parameters_schema` (`dict[str, Any]`): 参数模式 -- `desc` (`str`): 工具描述 -- `func_obj`: 工具函数 -- `active` (`bool`): 是否激活 - -**返回**: `list[str]` - 注册的工具名称列表 - ---- - -#### `unregister_llm_tool(name)` - -注销 LLM 工具。 - -```python -@classmethod -async def unregister_llm_tool(cls, name: str) -> bool: - return await cls._require_context().unregister_llm_tool(name) -``` - -**参数**: -- `name` (`str`): 工具名称 - -**返回**: `bool` - 是否成功注销 - ---- - -### 使用示例 - -```python -from astrbot_sdk import StarTools -from astrbot_sdk.events import MessageEvent - -class MyPlugin(Star): - async def on_start(self, ctx): - # 注册 LLM 工具 - await StarTools.register_llm_tool( - name="my_tool", - parameters_schema={ - "type": "object", - "properties": { - "text": {"type": "string"} - } - }, - desc="我的工具", - func_obj=self.my_tool_func - ) - - async def my_tool_func(self, text: str) -> str: - return f"处理结果: {text}" - - @on_command("test") - async def test(self, event: MessageEvent): - # 发送消息 - await StarTools.send_message( - event.session, - "Hello!" - ) - - # 激活工具 - await StarTools.activate_llm_tool("my_tool") -``` - ---- - -## PluginKVStoreMixin - KV 存储混入 - -插件作用域的 KV 存储助手,基于运行时 db 客户端。 - -### 类定义 - -```python -class PluginKVStoreMixin: - """Plugin-scoped KV helpers backed by the runtime db client.""" -``` - -### 属性 - -#### `plugin_id` - -获取插件 ID。 - -```python -@property -def plugin_id(self) -> str: - ctx = self._runtime_context() - return ctx.plugin_id -``` - -### 实例方法 - -#### `put_kv_data(key, value)` - -存储键值数据。 - -```python -async def put_kv_data(self, key: str, value: Any) -> None: - ctx = self._runtime_context() - await ctx.db.set(str(key), value) -``` - -**参数**: -- `key` (`str`): 键名 -- `value` (`Any`): 值 - ---- - -#### `get_kv_data(key, default)` - -获取键值数据。 - -```python -async def get_kv_data(self, key: str, default: _VT) -> _VT: - ctx = self._runtime_context() - value = await ctx.db.get(str(key)) - return default if value is None else value -``` - -**参数**: -- `key` (`str`): 键名 -- `default`: 默认值 - -**返回**: 存储的值或默认值 - ---- - -#### `delete_kv_data(key)` - -删除键值数据。 - -```python -async def delete_kv_data(self, key: str) -> None: - ctx = self._runtime_context() - await ctx.db.delete(str(key)) -``` - -**参数**: -- `key` (`str`): 键名 - ---- - -### 使用示例 - -```python -from astrbot_sdk import Star, PluginKVStoreMixin - -class MyPlugin(Star, PluginKVStoreMixin): - async def on_start(self, ctx): - # 存储数据 - await self.put_kv_data("initialized", True) - await self.put_kv_data("config", {"key": "value"}) - - @on_command("config") - async def config_command(self, event: MessageEvent, key: str, value: str): - # 保存配置 - await self.put_kv_data(f"config_{key}", value) - await event.reply(f"配置已保存: {key} = {value}") - - @on_command("get_config") - async def get_config(self, event: MessageEvent, key: str): - # 读取配置 - value = await self.get_kv_data(f"config_{key}", default="未设置") - await event.reply(f"{key} = {value}") - - @on_command("delete_config") - async def delete_config(self, event: MessageEvent, key: str): - # 删除配置 - await self.delete_kv_data(f"config_{key}") - await event.reply(f"配置已删除: {key}") -``` - ---- - -## 相关模块 - -- **核心类**: `astrbot_sdk.star.Star`, `astrbot_sdk.context.Context` -- **事件处理**: `astrbot_sdk.events.MessageEvent` -- **装饰器**: `astrbot_sdk.decorators` - ---- - -**版本**: v4.0 -**最后更新**: 2026-03-17 diff --git a/astrbot-sdk/src/astrbot_sdk/errors.py b/astrbot-sdk/src/astrbot_sdk/errors.py deleted file mode 100644 index ffe267a0c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/errors.py +++ /dev/null @@ -1,311 +0,0 @@ -"""跨运行时边界传递的统一错误模型。 - -AstrBotError 是 SDK 中所有可预期错误的标准格式, -支持跨进程传递(通过 to_payload/from_payload 序列化)。 - -错误处理流程: - 1. 运行时抛出 AstrBotError 子类或实例 - 2. 错误被捕获并序列化为 payload - 3. 跨进程传输后反序列化 - 4. 在 on_error 钩子中统一处理 - -Example: - # 抛出错误 - raise AstrBotError.invalid_input("参数不能为空") - - # 捕获并处理 - try: - await some_operation() - except AstrBotError as e: - if e.retryable: - # 可重试的错误 - await retry() - else: - # 不可重试的错误 - await event.reply(e.hint or e.message) -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -class ErrorCodes: - """AstrBot v4 的稳定错误码常量。 - - 这些错误码在协议层稳定,不应随意更改。 - 新增错误码应放在对应分类的末尾。 - - 分类: - - 不可重试错误(retryable=False):配置错误、权限错误等 - - 可重试错误(retryable=True):网络超时、临时故障等 - """ - - UNKNOWN_ERROR = "unknown_error" - - # 不可重试错误 - 配置或使用问题 - LLM_NOT_CONFIGURED = "llm_not_configured" - CAPABILITY_NOT_FOUND = "capability_not_found" - PERMISSION_DENIED = "permission_denied" - LLM_ERROR = "llm_error" - INVALID_INPUT = "invalid_input" - CANCELLED = "cancelled" - PROTOCOL_VERSION_MISMATCH = "protocol_version_mismatch" - PROTOCOL_ERROR = "protocol_error" - INTERNAL_ERROR = "internal_error" - RATE_LIMITED = "rate_limited" - COOLDOWN_ACTIVE = "cooldown_active" - - # 可重试错误 - 临时故障 - CAPABILITY_TIMEOUT = "capability_timeout" - NETWORK_ERROR = "network_error" - LLM_TEMPORARY_ERROR = "llm_temporary_error" - - -@dataclass(slots=True) -class AstrBotError(Exception): - """AstrBot SDK 的标准错误类型。 - - 所有可预期的错误都应使用此类或其工厂方法创建。 - 支持跨进程传递,包含用户友好的提示信息。 - - Attributes: - code: 错误码,来自 ErrorCodes 常量 - message: 错误消息,面向开发者 - hint: 用户提示,面向终端用户 - retryable: 是否可重试 - - Example: - # 使用工厂方法创建错误 - raise AstrBotError.invalid_input("参数格式错误", hint="请使用 JSON 格式") - - # 检查错误类型 - try: - await operation() - except AstrBotError as e: - if e.code == ErrorCodes.CAPABILITY_NOT_FOUND: - logger.error(f"能力不存在: {e.message}") - """ - - code: str - message: str - hint: str = "" - retryable: bool = False - docs_url: str = "" - details: dict[str, Any] | None = None - - def __str__(self) -> str: - return self.message - - @classmethod - def cancelled(cls, message: str = "调用被取消") -> AstrBotError: - """创建取消错误。 - - Args: - message: 错误消息 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.CANCELLED, - message=message, - hint="", - retryable=False, - ) - - @classmethod - def capability_not_found(cls, name: str) -> AstrBotError: - """创建能力未找到错误。 - - Args: - name: 未找到的能力名称 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.CAPABILITY_NOT_FOUND, - message=f"未找到能力:{name}", - hint="请确认 AstrBot Core 是否已注册该 capability", - retryable=False, - ) - - @classmethod - def invalid_input( - cls, - message: str, - *, - hint: str = "请检查调用参数", - docs_url: str = "", - details: dict[str, Any] | None = None, - ) -> AstrBotError: - """创建输入无效错误。 - - Args: - message: 详细错误消息 - hint: 用户提示 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.INVALID_INPUT, - message=message, - hint=hint, - retryable=False, - docs_url=docs_url, - details=details, - ) - - @classmethod - def protocol_version_mismatch(cls, message: str) -> AstrBotError: - """创建协议版本不匹配错误。 - - Args: - message: 详细错误消息 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.PROTOCOL_VERSION_MISMATCH, - message=message, - hint="请升级 astrbot_sdk 至最新版本", - retryable=False, - ) - - @classmethod - def protocol_error(cls, message: str) -> AstrBotError: - """创建协议错误。 - - Args: - message: 详细错误消息 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.PROTOCOL_ERROR, - message=message, - hint="请检查通信双方的协议实现", - retryable=False, - ) - - @classmethod - def internal_error( - cls, - message: str, - *, - hint: str = "请联系插件作者", - docs_url: str = "", - details: dict[str, Any] | None = None, - ) -> AstrBotError: - """创建内部错误。 - - Args: - message: 详细错误消息 - hint: 用户提示 - - Returns: - AstrBotError 实例 - """ - return cls( - code=ErrorCodes.INTERNAL_ERROR, - message=message, - hint=hint, - retryable=False, - docs_url=docs_url, - details=details, - ) - - @classmethod - def network_error( - cls, - message: str, - *, - hint: str = "网络请求失败,请稍后重试", - docs_url: str = "", - details: dict[str, Any] | None = None, - ) -> AstrBotError: - return cls( - code=ErrorCodes.NETWORK_ERROR, - message=message, - hint=hint, - retryable=True, - docs_url=docs_url, - details=details, - ) - - @classmethod - def rate_limited( - cls, - *, - hint: str = "操作过于频繁,请稍后再试。", - details: dict[str, Any] | None = None, - ) -> AstrBotError: - return cls( - code=ErrorCodes.RATE_LIMITED, - message="handler invocation is rate limited", - hint=hint, - retryable=False, - details=details, - ) - - @classmethod - def cooldown_active( - cls, - *, - hint: str, - details: dict[str, Any] | None = None, - ) -> AstrBotError: - return cls( - code=ErrorCodes.COOLDOWN_ACTIVE, - message="handler cooldown is active", - hint=hint, - retryable=False, - details=details, - ) - - def to_payload(self) -> dict[str, object]: - """序列化为可传输的字典格式。 - - 用于跨进程传递错误信息。 - - Returns: - 包含错误信息的字典 - """ - return { - "code": self.code, - "message": self.message, - "hint": self.hint, - "retryable": self.retryable, - "docs_url": self.docs_url, - "details": dict(self.details) if isinstance(self.details, dict) else None, - } - - @classmethod - def from_payload(cls, payload: dict[str, object]) -> AstrBotError: - """从字典反序列化错误实例。 - - Args: - payload: 包含错误信息的字典 - - Returns: - AstrBotError 实例 - """ - details_payload = payload.get("details") - details = ( - {str(key): value for key, value in details_payload.items()} - if isinstance(details_payload, dict) - else None - ) - return cls( - code=str(payload.get("code", ErrorCodes.UNKNOWN_ERROR)), - message=str(payload.get("message", "未知错误")), - hint=str(payload.get("hint", "")), - retryable=bool(payload.get("retryable", False)), - docs_url=str(payload.get("docs_url", "")), - details=details, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/events.py b/astrbot-sdk/src/astrbot_sdk/events.py deleted file mode 100644 index 05021aa99..000000000 --- a/astrbot-sdk/src/astrbot_sdk/events.py +++ /dev/null @@ -1,747 +0,0 @@ -"""v4 原生事件对象。 - -顶层 ``MessageEvent`` 保持精简,只承载 v4 运行时真正需要的基础能力。 -迁移期扩展事件能力放在独立模块中,而不是继续塞回顶层事件类型。 - -MessageEvent 是 handler 接收的主要事件类型,封装了: - - 消息文本内容 - - 发送者信息(user_id, group_id) - - 平台标识 - - 回复能力(reply, reply_image, reply_chain) -""" - -from __future__ import annotations - -import json -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from .message.components import ( - At, - BaseMessageComponent, - File, - Image, - Plain, - component_to_payload_sync, - payloads_to_components, -) -from .message.result import EventResultType, MessageChain, MessageEventResult -from .protocol.descriptors import SessionRef - -if TYPE_CHECKING: - from .context import Context - - -@dataclass(slots=True) -class PlainTextResult: - """纯文本结果。 - - 用于 handler 返回简单的文本结果。 - """ - - text: str - - -ReplyHandler = Callable[[str], Awaitable[None]] - -_JSON_DROP = object() - - -def _coerce_str(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value - return str(value) - - -def _coerce_optional_str(value: Any) -> str | None: - if value is None: - return None - text = value if isinstance(value, str) else str(value) - return text or None - - -def _json_safe_value(value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, (list, tuple)): - items = [] - for item in value: - normalized = _json_safe_value(item) - if normalized is not _JSON_DROP: - items.append(normalized) - return items - if isinstance(value, dict): - normalized_dict: dict[str, Any] = {} - for key, item in value.items(): - normalized = _json_safe_value(item) - if normalized is not _JSON_DROP: - normalized_dict[str(key)] = normalized - return normalized_dict - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - try: - return _json_safe_value(model_dump()) - except Exception: - return _JSON_DROP - try: - json.dumps(value) - except (TypeError, ValueError): - return _JSON_DROP - return value - - -def _json_safe_mapping(value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - return {} - normalized: dict[str, Any] = {} - for key, item in value.items(): - safe_item = _json_safe_value(item) - if safe_item is not _JSON_DROP: - normalized[str(key)] = safe_item - return normalized - - -class MessageEvent: - """消息事件对象。 - - 封装收到的消息,提供便捷的回复方法。 - 每个 handler 调用都会创建新的 MessageEvent 实例。 - - Attributes: - text: 消息文本内容 - user_id: 发送者用户 ID,缺失时为空字符串 - group_id: 群组 ID(私聊时为 None) - platform: 平台标识(如 "qq", "wechat"),缺失时为空字符串 - session_id: 会话 ID(通常是 group_id 或 user_id,缺失时为空字符串) - raw: 原始消息数据 - - Example: - @on_command("echo") - async def echo(self, event: MessageEvent, ctx: Context): - await event.reply(f"你说: {event.text}") - """ - - text: str - user_id: str - group_id: str | None - platform: str - session_id: str - self_id: str - platform_id: str - message_type: str - sender_name: str - - def __init__( - self, - *, - text: str = "", - user_id: str | None = None, - group_id: str | None = None, - platform: str | None = None, - session_id: str | None = None, - self_id: str | None = None, - platform_id: str | None = None, - message_type: str | None = None, - sender_name: str | None = None, - is_admin: bool = False, - raw: dict[str, Any] | None = None, - context: Context | None = None, - reply_handler: ReplyHandler | None = None, - ) -> None: - """初始化消息事件。 - - Args: - text: 消息文本 - user_id: 用户 ID - group_id: 群组 ID - platform: 平台标识 - session_id: 会话 ID,None 时自动从 group_id/user_id 推断 - raw: 原始消息数据 - context: 运行时上下文 - reply_handler: 自定义回复处理器 - """ - normalized_user_id = _coerce_str(user_id) - normalized_group_id = _coerce_optional_str(group_id) - normalized_platform = _coerce_str(platform) - normalized_session_id = _coerce_str(session_id) - - self.text = text - self.user_id = normalized_user_id - self.group_id = normalized_group_id - self.platform = normalized_platform - self.session_id = ( - normalized_session_id or normalized_group_id or normalized_user_id or "" - ) - self.self_id = _coerce_str(self_id) - self.platform_id = _coerce_str(platform_id) or normalized_platform - self.message_type = _coerce_str(message_type).lower() - self.sender_name = _coerce_str(sender_name) - self._is_admin = bool(is_admin) - self.raw = raw or {} - self._stopped = False - host_extras = self.raw.get("host_extras") - raw_extras = self.raw.get("extras") - self._host_extras = _json_safe_mapping( - host_extras if isinstance(host_extras, dict) else raw_extras - ) - self._host_extras_present = "host_extras" in self.raw or "extras" in self.raw - sdk_local_extras = self.raw.get("sdk_local_extras") - self._sdk_local_extras = _json_safe_mapping(sdk_local_extras) - self._sdk_local_extras_present = "sdk_local_extras" in self.raw - self._sdk_local_extras_dirty = False - messages_payload = self.raw.get("messages") - self._messages = ( - payloads_to_components(messages_payload) - if isinstance(messages_payload, list) - else [] - ) - self._messages_present = "messages" in self.raw - self._message_outline = str(self.raw.get("message_outline", self.text)) - sent_messages_payload = self.raw.get("sent_messages") - self._sent_messages = ( - payloads_to_components(sent_messages_payload) - if isinstance(sent_messages_payload, list) - else [] - ) - self._sent_messages_present = "sent_messages" in self.raw - self._sent_message_outline = str(self.raw.get("sent_message_outline", "")) - self._sent_message_outline_present = "sent_message_outline" in self.raw - self._context = context - self._reply_handler = reply_handler - if self._reply_handler is None and context is not None: - self._reply_handler = lambda text: context.platform.send( - self.session_ref or self.session_id, - text, - ) - - def _require_runtime_context(self, action: str) -> Context: - """获取运行时上下文,不存在则抛出异常。""" - if self._context is None: - raise RuntimeError(f"MessageEvent 未绑定运行时上下文,无法 {action}") - return self._context - - def _reply_target(self) -> SessionRef | str: - """获取回复目标。""" - return self.session_ref or self.session_id - - @classmethod - def from_payload( - cls, - payload: dict[str, Any], - *, - context: Context | None = None, - reply_handler: ReplyHandler | None = None, - ) -> MessageEvent: - """从协议载荷创建事件实例。 - - Args: - payload: 协议层传递的消息数据 - context: 运行时上下文 - reply_handler: 自定义回复处理器 - - Returns: - 新的 MessageEvent 实例 - """ - target_payload = payload.get("target") - session_id = payload.get("session_id") - platform = payload.get("platform") - if isinstance(target_payload, dict): - target = SessionRef.model_validate(target_payload) - session_id = session_id or target.session - platform = platform or target.platform - return cls( - text=str(payload.get("text", "")), - user_id=payload.get("user_id"), - group_id=payload.get("group_id"), - platform=platform, - session_id=session_id, - self_id=payload.get("self_id"), - platform_id=payload.get("platform_id"), - message_type=payload.get("message_type"), - sender_name=payload.get("sender_name"), - is_admin=bool(payload.get("is_admin", False)), - raw=payload, - context=context, - reply_handler=reply_handler, - ) - - def to_payload(self) -> dict[str, Any]: - """转换为协议载荷格式。 - - Returns: - 可序列化的字典 - """ - payload = dict(self.raw) - payload.update( - { - "text": self.text, - "user_id": self.user_id, - "group_id": self.group_id, - "platform": self.platform, - "session_id": self.session_id, - "self_id": self.self_id, - "platform_id": self.platform_id, - "message_type": self.message_type, - "sender_name": self.sender_name, - "is_admin": self._is_admin, - } - ) - if self.session_ref is not None: - payload["target"] = self.session_ref.to_payload() - merged_extras = dict(self._host_extras) - merged_extras.update(self._sdk_local_extras_payload()) - if merged_extras: - payload["extras"] = merged_extras - elif self._host_extras_present: - payload["extras"] = {} - else: - payload.pop("extras", None) - if self._host_extras or self._host_extras_present: - payload["host_extras"] = dict(self._host_extras) - else: - payload.pop("host_extras", None) - sdk_local_extras = self._sdk_local_extras_payload() - if sdk_local_extras or self._should_serialize_sdk_local_extras(): - payload["sdk_local_extras"] = sdk_local_extras - else: - payload.pop("sdk_local_extras", None) - if self._messages or self._messages_present: - payload["messages"] = [ - component_to_payload_sync(component) for component in self._messages - ] - else: - payload.pop("messages", None) - payload["message_outline"] = self._message_outline - if self._sent_messages or self._sent_messages_present: - payload["sent_messages"] = [ - component_to_payload_sync(component) - for component in self._sent_messages - ] - else: - payload.pop("sent_messages", None) - if self._sent_message_outline or self._sent_message_outline_present: - payload["sent_message_outline"] = self._sent_message_outline - else: - payload.pop("sent_message_outline", None) - return payload - - @property - def session_ref(self) -> SessionRef | None: - """获取会话引用对象。 - - Returns: - SessionRef 实例,如果没有有效的 session_id 则返回 None - """ - if not self.session_id: - return None - return SessionRef( - conversation_id=self.session_id, - platform=self.platform, - raw=self.raw or None, - ) - - @property - def target(self) -> SessionRef | None: - """session_ref 的别名。""" - return self.session_ref - - @property - def unified_msg_origin(self) -> str: - """Unified message origin string.""" - return self.session_id - - def is_private_chat(self) -> bool: - """Whether the current event belongs to a private chat.""" - if self.message_type: - return self.message_type == "private" - return not bool(self.group_id) - - def is_group_chat(self) -> bool: - if self.message_type: - return self.message_type == "group" - return bool(self.group_id) - - def get_platform_id(self) -> str: - """Get the platform instance identifier.""" - return self.platform_id - - def get_message_type(self) -> str: - """Get the normalized message type.""" - return self.message_type - - def get_session_id(self) -> str: - """Get the current session identifier.""" - return self.session_id - - def is_admin(self) -> bool: - """Whether the sender has admin permission.""" - return self._is_admin - - def get_messages(self) -> list[BaseMessageComponent]: - """Return SDK message components for the current event.""" - return list(self._messages) - - def get_sent_messages(self) -> list[BaseMessageComponent]: - """Return outbound SDK message components for after-send events.""" - return list(self._sent_messages) - - def has_component(self, type_: type[BaseMessageComponent]) -> bool: - return any(isinstance(component, type_) for component in self._messages) - - def get_components( - self, - type_: type[BaseMessageComponent], - ) -> list[BaseMessageComponent]: - return [ - component for component in self._messages if isinstance(component, type_) - ] - - def get_images(self) -> list[Image]: - return [ - component for component in self._messages if isinstance(component, Image) - ] - - def get_files(self) -> list[File]: - return [ - component for component in self._messages if isinstance(component, File) - ] - - def extract_plain_text(self) -> str: - return " ".join( - component.text - for component in self._messages - if isinstance(component, Plain) - ) - - def get_at_users(self) -> list[str]: - return [ - str(component.qq) - for component in self._messages - if isinstance(component, At) and str(component.qq).lower() != "all" - ] - - def get_message_outline(self) -> str: - """Return the normalized message outline.""" - return self._message_outline - - def get_sent_message_outline(self) -> str: - """Return the outbound message outline for after-send events.""" - return self._sent_message_outline - - async def get_group(self) -> dict[str, Any] | None: - """Get current-group metadata for the bound message request.""" - context = self._require_runtime_context("get_group") - output = await context._proxy.call( # noqa: SLF001 - "platform.get_group", - { - "session": self.session_id, - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - payload = output.get("group") - if not isinstance(payload, dict): - return None - return dict(payload) - - def set_extra(self, key: str, value: Any) -> None: - """Store SDK-local transient event data.""" - self._sdk_local_extras[key] = value - self._sdk_local_extras_dirty = True - - def get_extra(self, key: str | None = None, default: Any = None) -> Any: - """Read SDK-local transient event data.""" - extras = dict(self._host_extras) - extras.update(self._sdk_local_extras) - if key is None: - return extras - return extras.get(key, default) - - def clear_extra(self) -> None: - """Clear SDK-local transient event data.""" - self._sdk_local_extras.clear() - self._sdk_local_extras_dirty = True - - def _sdk_local_extras_payload(self) -> dict[str, Any]: - return _json_safe_mapping(self._sdk_local_extras) - - def _should_serialize_sdk_local_extras(self) -> bool: - return ( - self._sdk_local_extras_present - or self._sdk_local_extras_dirty - or bool(self._sdk_local_extras) - ) - - async def request_llm(self) -> bool: - """Request the default LLM chain for the current message request.""" - context = self._require_runtime_context("request_llm") - output = await context._proxy.call( # noqa: SLF001 - "system.event.llm.request", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - return bool(output.get("should_call_llm", False)) - - async def should_call_llm(self) -> bool: - """Read the current default-LLM decision from the host bridge.""" - context = self._require_runtime_context("should_call_llm") - output = await context._proxy.call( # noqa: SLF001 - "system.event.llm.get_state", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - return bool(output.get("should_call_llm", False)) - - async def set_result(self, result: MessageEventResult) -> MessageEventResult: - """Store a request-scoped SDK result in the host bridge.""" - context = self._require_runtime_context("set_result") - await context._proxy.call( # noqa: SLF001 - "system.event.result.set", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - "result": result.to_payload(), - }, - ) - return result - - async def get_result(self) -> MessageEventResult | None: - """Read the current request-scoped SDK result from the host bridge.""" - context = self._require_runtime_context("get_result") - output = await context._proxy.call( # noqa: SLF001 - "system.event.result.get", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - payload = output.get("result") - if not isinstance(payload, dict): - return None - return MessageEventResult.from_payload(payload) - - async def clear_result(self) -> None: - """Clear the current request-scoped SDK result.""" - context = self._require_runtime_context("clear_result") - await context._proxy.call( # noqa: SLF001 - "system.event.result.clear", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - - def stop_event(self) -> None: - """Mark the SDK-local event as stopped.""" - self._stopped = True - - def continue_event(self) -> None: - """Clear the SDK-local stop flag.""" - self._stopped = False - - def is_stopped(self) -> bool: - """Return whether the SDK-local event is stopped.""" - return self._stopped - - async def reply(self, text: str) -> None: - """回复文本消息。 - - Args: - text: 要回复的文本内容 - - Raises: - RuntimeError: 如果未绑定 reply handler - """ - if self._reply_handler is None: - raise RuntimeError("MessageEvent 未绑定 reply handler,无法 reply") - await self._reply_handler(text) - - async def reply_image(self, image_url: str) -> None: - """回复图片消息。 - - Args: - image_url: 图片 URL - - Raises: - RuntimeError: 如果未绑定运行时上下文 - """ - context = self._require_runtime_context("reply_image") - await context.platform.send_image(self._reply_target(), image_url) - - async def reply_chain( - self, - chain: MessageChain | list[BaseMessageComponent] | list[dict[str, Any]], - ) -> None: - """回复消息链(多类型消息组合)。 - - Args: - chain: 消息链组件列表 - - Raises: - RuntimeError: 如果未绑定运行时上下文 - """ - context = self._require_runtime_context("reply_chain") - await context.platform.send_chain(self._reply_target(), chain) - - async def react(self, emoji: str) -> bool: - """Send a platform reaction when supported.""" - context = self._require_runtime_context("react") - output = await context._proxy.call( # noqa: SLF001 - "system.event.react", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - "emoji": emoji, - }, - ) - return bool(output.get("supported", False)) - - async def send_typing(self) -> bool: - """Emit typing state when the host platform supports it.""" - context = self._require_runtime_context("send_typing") - output = await context._proxy.call( # noqa: SLF001 - "system.event.send_typing", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - }, - ) - return bool(output.get("supported", False)) - - async def send_streaming( - self, - generator, - use_fallback: bool = False, - ) -> bool: - """Replay normalized chunks through the host streaming pathway.""" - context = self._require_runtime_context("send_streaming") - output = await context._proxy.call( # noqa: SLF001 - "system.event.send_streaming", - { - "target": ( - self.session_ref.to_payload() - if self.session_ref is not None - else None - ), - "use_fallback": use_fallback, - }, - ) - if not bool(output.get("supported", False)): - return False - - stream_id = str(output.get("stream_id", "")) - if not stream_id: - return False - - try: - async for item in generator: - if isinstance(item, str): - chain = MessageChain([Plain(item, convert=False)]) - else: - chain = self._coerce_chain_or_raise(item) - await context._proxy.call( # noqa: SLF001 - "system.event.send_streaming_chunk", - { - "stream_id": stream_id, - "chain": await chain.to_payload_async(), - }, - ) - finally: - output = await context._proxy.call( # noqa: SLF001 - "system.event.send_streaming_close", - {"stream_id": stream_id}, - ) - return bool(output.get("supported", False)) - - def bind_reply_handler(self, reply_handler: ReplyHandler) -> None: - """绑定自定义回复处理器。 - - Args: - reply_handler: 回复处理函数 - """ - self._reply_handler = reply_handler - - def plain_result(self, text: str) -> PlainTextResult: - """创建纯文本结果。 - - Args: - text: 结果文本 - - Returns: - PlainTextResult 实例 - """ - return PlainTextResult(text=text) - - def make_result(self) -> MessageEventResult: - """Create an empty SDK-local result wrapper.""" - return MessageEventResult(type=EventResultType.EMPTY) - - def image_result(self, url_or_path: str) -> MessageEventResult: - """Create a chain result that contains one image component.""" - if url_or_path.startswith(("http://", "https://")): - image = Image.fromURL(url_or_path) - elif url_or_path.startswith("base64://"): - image = Image.fromBase64(url_or_path.removeprefix("base64://")) - else: - image = Image.fromFileSystem(url_or_path) - return MessageEventResult( - type=EventResultType.CHAIN, - chain=MessageChain([image]), - ) - - def chain_result( - self, - chain: MessageChain | list[BaseMessageComponent], - ) -> MessageEventResult: - """Create a chain result from SDK components.""" - normalized = ( - chain if isinstance(chain, MessageChain) else MessageChain(list(chain)) - ) - return MessageEventResult(type=EventResultType.CHAIN, chain=normalized) - - @staticmethod - def _coerce_chain_or_raise(item: Any) -> MessageChain: - if isinstance(item, MessageEventResult): - return item.chain - if isinstance(item, MessageChain): - return item - if isinstance(item, BaseMessageComponent): - return MessageChain([item]) - if isinstance(item, list) and all( - isinstance(component, BaseMessageComponent) for component in item - ): - return MessageChain(list(item)) - raise TypeError( - "send_streaming only accepts str, MessageChain, MessageEventResult or SDK message components" - ) diff --git a/astrbot-sdk/src/astrbot_sdk/filters.py b/astrbot-sdk/src/astrbot_sdk/filters.py deleted file mode 100644 index 7951cb0c0..000000000 --- a/astrbot-sdk/src/astrbot_sdk/filters.py +++ /dev/null @@ -1,218 +0,0 @@ -"""SDK-native filter declarations. - -本模块提供事件过滤器的声明式 API,用于在 handler 执行前进行条件判断。 - -内置过滤器类型: -- PlatformFilter: 按平台名称过滤(如 qq、wechat) -- MessageTypeFilter: 按消息类型过滤(如 group、private) -- CustomFilter: 用户自定义的同步布尔函数 - -组合操作: -- all_of(*filters): 所有过滤器都通过才执行(AND 逻辑) -- any_of(*filters): 任一过滤器通过即可执行(OR 逻辑) -- 支持 & 和 | 运算符进行链式组合 - -过滤器在本地(SDK worker 进程内)求值,避免不必要的跨进程调用。 -""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any, Literal, TypeAlias - -from .decorators import append_filter_meta -from .protocol.descriptors import ( - CompositeFilterSpec, - FilterSpec, - LocalFilterRefSpec, - MessageTypeFilterSpec, - PlatformFilterSpec, -) - -FilterOperator: TypeAlias = Literal["and", "or"] - - -@dataclass(slots=True) -class LocalFilterBinding: - filter_id: str - callable: Callable[..., bool] - args: dict[str, Any] = field(default_factory=dict) - - def evaluate(self, *, event=None, ctx=None) -> bool: - signature = inspect.signature(self.callable) - kwargs: dict[str, Any] = {} - if "event" in signature.parameters: - kwargs["event"] = event - if "ctx" in signature.parameters: - kwargs["ctx"] = ctx - result = self.callable(**kwargs) - if inspect.isawaitable(result): - raise TypeError("CustomFilter must return a synchronous bool") - if not isinstance(result, bool): - raise TypeError("CustomFilter must return bool") - return result - - -class FilterBinding: - def __and__(self, other: FilterBinding) -> CompositeFilter: - return CompositeFilter("and", [self, other]) - - def __or__(self, other: FilterBinding) -> CompositeFilter: - return CompositeFilter("or", [self, other]) - - def compile(self) -> tuple[FilterSpec, list[LocalFilterBinding]]: - raise NotImplementedError - - -@dataclass(slots=True) -class PlatformFilter(FilterBinding): - platforms: list[str] - - def compile(self) -> tuple[FilterSpec, list[LocalFilterBinding]]: - return PlatformFilterSpec(platforms=list(self.platforms)), [] - - -@dataclass(slots=True) -class MessageTypeFilter(FilterBinding): - message_types: list[str] - - def compile(self) -> tuple[FilterSpec, list[LocalFilterBinding]]: - return MessageTypeFilterSpec(message_types=list(self.message_types)), [] - - -@dataclass(slots=True) -class CustomFilter(FilterBinding): - callable: Callable[..., bool] - filter_id: str | None = None - - def __post_init__(self) -> None: - if self.filter_id is None: - self.filter_id = f"{self.callable.__module__}.{getattr(self.callable, '__qualname__', self.callable.__name__)}" - - def compile(self) -> tuple[FilterSpec, list[LocalFilterBinding]]: - assert self.filter_id is not None - return LocalFilterRefSpec(filter_id=self.filter_id), [ - LocalFilterBinding(filter_id=self.filter_id, callable=self.callable), - ] - - -@dataclass(slots=True) -class CompositeFilter(FilterBinding): - operator: FilterOperator - children: list[FilterBinding] - - def compile(self) -> tuple[FilterSpec, list[LocalFilterBinding]]: - compiled_children: list[FilterSpec] = [] - local_bindings: list[LocalFilterBinding] = [] - for child in self.children: - spec, locals_for_child = child.compile() - compiled_children.append(spec) - local_bindings.extend(locals_for_child) - - if local_bindings: - filter_id = ( - "composite:" - + ":".join(binding.filter_id for binding in local_bindings) - + f":{self.operator}" - ) - - def _evaluate(*, event=None, ctx=None) -> bool: - results = [ - _evaluate_filter_spec_locally( - spec, local_bindings, event=event, ctx=ctx - ) - for spec in compiled_children - ] - if self.operator == "and": - return all(results) - return any(results) - - return ( - LocalFilterRefSpec(filter_id=filter_id), - [LocalFilterBinding(filter_id=filter_id, callable=_evaluate)], - ) - - return CompositeFilterSpec(kind=self.operator, children=compiled_children), [] - - -def _evaluate_filter_spec_locally( - spec: FilterSpec, - local_bindings: list[LocalFilterBinding], - *, - event=None, - ctx=None, -) -> bool: - if isinstance(spec, PlatformFilterSpec): - if event is None: - return True - platform = getattr(event, "platform", "") or "" - return platform in spec.platforms - if isinstance(spec, MessageTypeFilterSpec): - if event is None: - return True - message_type = getattr(event, "message_type", "") or "" - return message_type in spec.message_types - if isinstance(spec, LocalFilterRefSpec): - binding = next( - (item for item in local_bindings if item.filter_id == spec.filter_id), - None, - ) - if binding is None: - # LocalFilterRefSpec 只在当前 worker 持有同名 local binding 时可真正执行。 - # 缺失 binding 往往意味着描述符来自远端/测试快照,此时保持 fail-open, - # 避免因为无法调用进程内函数而把原本可执行的 handler 错误过滤掉。 - return True - return binding.evaluate(event=event, ctx=ctx) - if isinstance(spec, CompositeFilterSpec): - results = [ - _evaluate_filter_spec_locally( - child, - local_bindings, - event=event, - ctx=ctx, - ) - for child in spec.children - ] - if spec.kind == "and": - return all(results) - return any(results) - return True - - -def custom_filter( - binding: FilterBinding, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Attach a filter declaration to a handler.""" - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - spec, local_bindings = binding.compile() - append_filter_meta( - func, - specs=[spec], - local_bindings=local_bindings, - ) - return func - - return decorator - - -def all_of(*bindings: FilterBinding) -> CompositeFilter: - return CompositeFilter("and", list(bindings)) - - -def any_of(*bindings: FilterBinding) -> CompositeFilter: - return CompositeFilter("or", list(bindings)) - - -__all__ = [ - "CustomFilter", - "FilterBinding", - "LocalFilterBinding", - "MessageTypeFilter", - "PlatformFilter", - "all_of", - "any_of", - "custom_filter", -] diff --git a/astrbot-sdk/src/astrbot_sdk/llm/__init__.py b/astrbot-sdk/src/astrbot_sdk/llm/__init__.py deleted file mode 100644 index 02e15b9d2..000000000 --- a/astrbot-sdk/src/astrbot_sdk/llm/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Canonical SDK LLM/tool/provider entrypoints for P0.5.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .agents import AgentSpec, BaseAgentRunner - from .entities import ( - LLMToolSpec, - ProviderMeta, - ProviderRequest, - ProviderType, - RerankResult, - ToolCallsResult, - ) - from .providers import ( - EmbeddingProvider, - ProviderProxy, - RerankProvider, - STTProvider, - TTSAudioChunk, - TTSProvider, - ) - from .tools import LLMToolManager - -__all__ = [ - "AgentSpec", - "BaseAgentRunner", - "EmbeddingProvider", - "LLMToolManager", - "LLMToolSpec", - "ProviderMeta", - "ProviderProxy", - "ProviderRequest", - "ProviderType", - "RerankProvider", - "RerankResult", - "STTProvider", - "TTSAudioChunk", - "TTSProvider", - "ToolCallsResult", -] - - -def __getattr__(name: str) -> Any: - if name in {"AgentSpec", "BaseAgentRunner"}: - from .agents import AgentSpec, BaseAgentRunner - - return {"AgentSpec": AgentSpec, "BaseAgentRunner": BaseAgentRunner}[name] - if name in { - "LLMToolSpec", - "ProviderMeta", - "ProviderRequest", - "ProviderType", - "RerankResult", - "ToolCallsResult", - }: - from .entities import ( - LLMToolSpec, - ProviderMeta, - ProviderRequest, - ProviderType, - RerankResult, - ToolCallsResult, - ) - - return { - "LLMToolSpec": LLMToolSpec, - "ProviderMeta": ProviderMeta, - "ProviderRequest": ProviderRequest, - "ProviderType": ProviderType, - "RerankResult": RerankResult, - "ToolCallsResult": ToolCallsResult, - }[name] - if name in { - "EmbeddingProvider", - "ProviderProxy", - "RerankProvider", - "STTProvider", - "TTSAudioChunk", - "TTSProvider", - }: - from .providers import ( - EmbeddingProvider, - ProviderProxy, - RerankProvider, - STTProvider, - TTSAudioChunk, - TTSProvider, - ) - - return { - "EmbeddingProvider": EmbeddingProvider, - "ProviderProxy": ProviderProxy, - "RerankProvider": RerankProvider, - "STTProvider": STTProvider, - "TTSAudioChunk": TTSAudioChunk, - "TTSProvider": TTSProvider, - }[name] - if name == "LLMToolManager": - from .tools import LLMToolManager - - return LLMToolManager - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/astrbot-sdk/src/astrbot_sdk/llm/agents.py b/astrbot-sdk/src/astrbot_sdk/llm/agents.py deleted file mode 100644 index 2a0f88729..000000000 --- a/astrbot-sdk/src/astrbot_sdk/llm/agents.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any - -from pydantic import BaseModel, ConfigDict, Field - -from .entities import ProviderRequest - -if TYPE_CHECKING: - from ..context import Context - - -class AgentSpec(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str - description: str = "" - tool_names: list[str] = Field(default_factory=list) - runner_class: str - - def to_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_none=True) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> AgentSpec: - return cls.model_validate(payload) - - -class BaseAgentRunner(ABC): - """P0.5 agent registration surface. - - P0.5 only supports agent registration metadata. Actual execution remains - owned by the core tool loop and is not directly callable from SDK plugins. - """ - - @abstractmethod - async def run(self, ctx: Context, request: ProviderRequest) -> Any: - raise NotImplementedError diff --git a/astrbot-sdk/src/astrbot_sdk/llm/entities.py b/astrbot-sdk/src/astrbot_sdk/llm/entities.py deleted file mode 100644 index ba252db24..000000000 --- a/astrbot-sdk/src/astrbot_sdk/llm/entities.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import enum -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - - -class _EntityModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - def to_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_none=True) - - -class ProviderType(str, enum.Enum): - CHAT_COMPLETION = "chat_completion" - SPEECH_TO_TEXT = "speech_to_text" - TEXT_TO_SPEECH = "text_to_speech" - EMBEDDING = "embedding" - RERANK = "rerank" - - -class ProviderMeta(_EntityModel): - id: str - model: str | None = None - type: str - provider_type: ProviderType = ProviderType.CHAT_COMPLETION - - @classmethod - def from_payload(cls, payload: dict[str, Any] | None) -> ProviderMeta | None: - if not isinstance(payload, dict): - return None - return cls.model_validate(payload) - - -class ToolCallsResult(_EntityModel): - tool_call_id: str | None = None - tool_name: str - content: str - success: bool = True - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> ToolCallsResult: - return cls.model_validate(payload) - - -class RerankResult(_EntityModel): - index: int - score: float - document: str - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> RerankResult: - return cls.model_validate(payload) - - -class LLMToolSpec(_EntityModel): - name: str - description: str = "" - parameters_schema: dict[str, Any] = Field( - default_factory=lambda: {"type": "object", "properties": {}} - ) - handler_ref: str | None = Field( - default=None, - description="Worker-side handler reference used to resolve the tool callable.", - ) - handler_capability: str | None = Field( - default=None, - description="Optional capability name override for executing this tool handler.", - ) - active: bool = True - - @classmethod - def create( - cls, - *, - name: str, - description: str = "", - parameters_schema: dict[str, Any] | None = None, - handler_ref: str | None = None, - handler_capability: str | None = None, - active: bool = True, - ) -> LLMToolSpec: - # Keep an explicit factory signature so static analyzers do not depend on - # Pydantic's generated __init__ when SDK call sites construct tool specs. - payload: dict[str, Any] = { - "name": name, - "description": description, - "parameters_schema": parameters_schema - if parameters_schema is not None - else {"type": "object", "properties": {}}, - "active": active, - } - if handler_ref is not None: - payload["handler_ref"] = handler_ref - if handler_capability is not None: - payload["handler_capability"] = handler_capability - return cls.from_payload(payload) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> LLMToolSpec: - return cls.model_validate(payload) - - -class ProviderRequest(_EntityModel): - prompt: str | None = None - system_prompt: str | None = None - session_id: str | None = None - contexts: list[dict[str, Any]] = Field(default_factory=list) - image_urls: list[str] = Field(default_factory=list) - tool_names: list[str] | None = None - tool_calls_result: list[ToolCallsResult] = Field(default_factory=list) - provider_id: str | None = None - model: str | None = None - temperature: float | None = None - max_steps: int | None = None - tool_call_timeout: int | None = None - - def to_payload(self) -> dict[str, Any]: - payload = super().to_payload() - payload["tool_calls_result"] = [ - item.to_payload() for item in self.tool_calls_result - ] - return payload - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> ProviderRequest: - normalized = dict(payload) - raw_results = normalized.get("tool_calls_result") - if isinstance(raw_results, list): - normalized["tool_calls_result"] = [ - ToolCallsResult.from_payload(item) - for item in raw_results - if isinstance(item, dict) - ] - return cls.model_validate(normalized) diff --git a/astrbot-sdk/src/astrbot_sdk/llm/providers.py b/astrbot-sdk/src/astrbot_sdk/llm/providers.py deleted file mode 100644 index 591e1d57d..000000000 --- a/astrbot-sdk/src/astrbot_sdk/llm/providers.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Provider-facing SDK entities and typed proxy helpers.""" - -from __future__ import annotations - -import base64 -from collections.abc import AsyncIterable, AsyncIterator -from dataclasses import dataclass - -from ..clients._proxy import CapabilityProxy -from .entities import ProviderMeta, ProviderType, RerankResult - - -@dataclass(slots=True) -class TTSAudioChunk: - audio: bytes - text: str | None = None - - -class _BaseProviderProxy: - def __init__(self, proxy: CapabilityProxy, meta: ProviderMeta) -> None: - self._proxy = proxy - self._meta = meta - - @property - def id(self) -> str: - return self._meta.id - - @property - def model(self) -> str | None: - return self._meta.model - - @property - def type(self) -> str: - return self._meta.type - - @property - def provider_type(self) -> ProviderType: - return self._meta.provider_type - - def meta(self) -> ProviderMeta: - return self._meta - - -class STTProvider(_BaseProviderProxy): - async def get_text(self, audio_url: str) -> str: - output = await self._proxy.call( - "provider.stt.get_text", - {"provider_id": self.id, "audio_url": str(audio_url)}, - ) - return str(output.get("text", "")) - - -class TTSProvider(_BaseProviderProxy): - def __init__( - self, - proxy: CapabilityProxy, - meta: ProviderMeta, - *, - supports_stream: bool = False, - ) -> None: - super().__init__(proxy, meta) - self._supports_stream = supports_stream - - async def get_audio(self, text: str) -> str: - output = await self._proxy.call( - "provider.tts.get_audio", - {"provider_id": self.id, "text": str(text)}, - ) - return str(output.get("audio_path", "")) - - def support_stream(self) -> bool: - return self._supports_stream - - async def get_audio_stream( - self, - text: str | AsyncIterable[str], - ) -> AsyncIterator[TTSAudioChunk]: - payload = await self._build_stream_payload(text) - async for chunk in self._proxy.stream("provider.tts.get_audio_stream", payload): - audio_base64 = str(chunk.get("audio_base64", "")) - yield TTSAudioChunk( - audio=base64.b64decode(audio_base64) if audio_base64 else b"", - text=( - str(chunk.get("text")) if chunk.get("text") is not None else None - ), - ) - - async def _build_stream_payload( - self, - text: str | AsyncIterable[str], - ) -> dict[str, object]: - payload: dict[str, object] = {"provider_id": self.id} - if isinstance(text, str): - payload["text"] = text - return payload - payload["text_chunks"] = [str(item) async for item in text] - return payload - - -class EmbeddingProvider(_BaseProviderProxy): - async def get_embedding(self, text: str) -> list[float]: - output = await self._proxy.call( - "provider.embedding.get_embedding", - {"provider_id": self.id, "text": str(text)}, - ) - embedding = output.get("embedding") - if not isinstance(embedding, list): - return [] - return [float(item) for item in embedding] - - async def get_embeddings(self, texts: list[str]) -> list[list[float]]: - output = await self._proxy.call( - "provider.embedding.get_embeddings", - { - "provider_id": self.id, - "texts": [str(item) for item in texts], - }, - ) - embeddings = output.get("embeddings") - if not isinstance(embeddings, list): - return [] - return [ - [float(value) for value in item] - for item in embeddings - if isinstance(item, list) - ] - - async def get_dim(self) -> int: - output = await self._proxy.call( - "provider.embedding.get_dim", - {"provider_id": self.id}, - ) - return int(output.get("dim", 0)) - - -class RerankProvider(_BaseProviderProxy): - async def rerank( - self, - query: str, - documents: list[str], - top_n: int | None = None, - ) -> list[RerankResult]: - output = await self._proxy.call( - "provider.rerank.rerank", - { - "provider_id": self.id, - "query": str(query), - "documents": [str(item) for item in documents], - "top_n": top_n, - }, - ) - results = output.get("results") - if not isinstance(results, list): - return [] - return [ - RerankResult.from_payload(item) - for item in results - if isinstance(item, dict) - ] - - -ProviderProxy = STTProvider | TTSProvider | EmbeddingProvider | RerankProvider - - -def provider_proxy_from_meta( - proxy: CapabilityProxy, - meta: ProviderMeta | None, - *, - tts_supports_stream: bool | None = None, -) -> ProviderProxy | None: - if meta is None: - return None - if meta.provider_type == ProviderType.SPEECH_TO_TEXT: - return STTProvider(proxy, meta) - if meta.provider_type == ProviderType.TEXT_TO_SPEECH: - return TTSProvider( - proxy, - meta, - supports_stream=bool(tts_supports_stream), - ) - if meta.provider_type == ProviderType.EMBEDDING: - return EmbeddingProvider(proxy, meta) - if meta.provider_type == ProviderType.RERANK: - return RerankProvider(proxy, meta) - return None - - -__all__ = [ - "EmbeddingProvider", - "ProviderMeta", - "ProviderProxy", - "ProviderType", - "RerankProvider", - "RerankResult", - "STTProvider", - "TTSAudioChunk", - "TTSProvider", - "provider_proxy_from_meta", -] diff --git a/astrbot-sdk/src/astrbot_sdk/llm/tools.py b/astrbot-sdk/src/astrbot_sdk/llm/tools.py deleted file mode 100644 index d1a67b30c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/llm/tools.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from .entities import LLMToolSpec - -if TYPE_CHECKING: - from ..clients._proxy import CapabilityProxy - - -class LLMToolManager: - def __init__(self, proxy: CapabilityProxy) -> None: - self._proxy = proxy - - async def list_registered(self) -> list[LLMToolSpec]: - output = await self._proxy.call("llm_tool.manager.get", {}) - items = output.get("registered") - if not isinstance(items, list): - return [] - return [ - LLMToolSpec.from_payload(item) for item in items if isinstance(item, dict) - ] - - async def list_active(self) -> list[LLMToolSpec]: - output = await self._proxy.call("llm_tool.manager.get", {}) - items = output.get("active") - if not isinstance(items, list): - return [] - return [ - LLMToolSpec.from_payload(item) for item in items if isinstance(item, dict) - ] - - async def activate(self, name: str) -> bool: - output = await self._proxy.call("llm_tool.manager.activate", {"name": name}) - return bool(output.get("activated", False)) - - async def deactivate(self, name: str) -> bool: - output = await self._proxy.call("llm_tool.manager.deactivate", {"name": name}) - return bool(output.get("deactivated", False)) - - async def add(self, *tools: LLMToolSpec) -> list[str]: - output = await self._proxy.call( - "llm_tool.manager.add", - {"tools": [tool.to_payload() for tool in tools]}, - ) - result = output.get("names") - if not isinstance(result, list): - return [] - return [str(item) for item in result] - - async def remove(self, name: str) -> bool: - output = await self._proxy.call("llm_tool.manager.remove", {"name": name}) - return bool(output.get("removed", False)) - - async def get(self, name: str) -> LLMToolSpec | None: - for tool in await self.list_registered(): - if tool.name == name: - return tool - return None diff --git a/astrbot-sdk/src/astrbot_sdk/message/__init__.py b/astrbot-sdk/src/astrbot_sdk/message/__init__.py deleted file mode 100644 index 4125a0db1..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message/__init__.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Message component, result, and session subpackage.""" - -from .components import ( - At as At, -) -from .components import ( - AtAll as AtAll, -) -from .components import ( - BaseMessageComponent as BaseMessageComponent, -) -from .components import ( - File as File, -) -from .components import ( - Forward as Forward, -) -from .components import ( - Image as Image, -) -from .components import ( - MediaHelper as MediaHelper, -) -from .components import ( - Plain as Plain, -) -from .components import ( - Poke as Poke, -) -from .components import ( - Record as Record, -) -from .components import ( - Reply as Reply, -) -from .components import ( - UnknownComponent as UnknownComponent, -) -from .components import ( - Video as Video, -) -from .components import ( - build_media_component_from_url as build_media_component_from_url, -) -from .components import ( - component_to_payload as component_to_payload, -) -from .components import ( - component_to_payload_sync as component_to_payload_sync, -) -from .components import ( - is_message_component as is_message_component, -) -from .components import ( - payload_to_component as payload_to_component, -) -from .components import ( - payloads_to_components as payloads_to_components, -) -from .result import ( - EventResultType as EventResultType, -) -from .result import ( - MessageBuilder as MessageBuilder, -) -from .result import ( - MessageChain as MessageChain, -) -from .result import ( - MessageEventResult as MessageEventResult, -) -from .result import ( - coerce_message_chain as coerce_message_chain, -) -from .session import MessageSession as MessageSession - -__all__ = [ - "At", - "AtAll", - "BaseMessageComponent", - "EventResultType", - "File", - "Forward", - "Image", - "MediaHelper", - "MessageBuilder", - "MessageChain", - "MessageEventResult", - "MessageSession", - "Plain", - "Poke", - "Record", - "Reply", - "UnknownComponent", - "Video", - "build_media_component_from_url", - "coerce_message_chain", - "component_to_payload", - "component_to_payload_sync", - "is_message_component", - "payload_to_component", - "payloads_to_components", -] diff --git a/astrbot-sdk/src/astrbot_sdk/message/components.py b/astrbot-sdk/src/astrbot_sdk/message/components.py deleted file mode 100644 index 752d35e06..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message/components.py +++ /dev/null @@ -1,622 +0,0 @@ -"""SDK message component compatibility layer. - -该模块有意避免在导入时导入遗留核心组件模块。 -SDK工作线程应该保持轻量级并且不能依赖于主机核心引导程序 -仅用于构造消息对象的路径。 -""" - -from __future__ import annotations - -import asyncio -import base64 -import inspect -import os -import tempfile -import uuid -from collections.abc import Mapping -from pathlib import Path -from typing import Any -from urllib.parse import urlparse -from urllib.request import urlretrieve - -from .._internal.star_runtime import current_runtime_context -from ..errors import AstrBotError - -_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"} -_RECORD_SUFFIXES = {".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a"} -_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".mkv", ".avi"} - - -def _temp_path(prefix: str, suffix: str = "") -> Path: - return Path(tempfile.gettempdir()) / f"{prefix}_{uuid.uuid4().hex}{suffix}" - - -def _guess_suffix_from_url(url: str, fallback: str = "") -> str: - suffix = Path(urlparse(url).path).suffix - return suffix or fallback - - -def _download_to_temp(url: str, prefix: str, fallback_suffix: str = "") -> str: - target = _temp_path(prefix, _guess_suffix_from_url(url, fallback_suffix)) - urlretrieve(url, target) - return str(target.resolve()) - - -async def _download_to_temp_async( - url: str, - prefix: str, - fallback_suffix: str = "", -) -> str: - return await asyncio.to_thread( - _download_to_temp, - url, - prefix, - fallback_suffix, - ) - - -def _stringify_mapping(mapping: Mapping[Any, Any]) -> dict[str, Any]: - return {str(key): value for key, value in mapping.items()} - - -async def _register_file_to_service(path: str) -> str: - context = current_runtime_context() - if context is None: - raise RuntimeError("message component file service requires runtime context") - return await context._register_file_url(path) - - -def _reply_chain_payloads_sync(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - return [component_to_payload_sync(item) for item in value] - - -async def _reply_chain_payloads(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - return [await component_to_payload(item) for item in value] - - -def _coerce_reply_chain(value: Any) -> list[BaseMessageComponent]: - if not isinstance(value, list): - return [] - if value and all(isinstance(item, BaseMessageComponent) for item in value): - return list(value) - return payloads_to_components(value) - - -def _component_type_name(component: Any) -> str: - raw_type = getattr(component, "type", "unknown") - normalized = getattr(raw_type, "value", raw_type) - return str(normalized or "unknown").lower() - - -def _resolve_media_kind(url: str, kind: str = "auto") -> str: - normalized_kind = str(kind).strip().lower() or "auto" - if normalized_kind != "auto": - return normalized_kind - suffix = Path(urlparse(url).path).suffix.lower() - if suffix in _IMAGE_SUFFIXES: - return "image" - if suffix in _RECORD_SUFFIXES: - return "record" - if suffix in _VIDEO_SUFFIXES: - return "video" - return "file" - - -def build_media_component_from_url( - url: str, - *, - kind: str = "auto", -) -> BaseMessageComponent: - url_text = str(url).strip() - if not url_text: - raise AstrBotError.invalid_input( - "MediaHelper.from_url requires a non-empty url" - ) - resolved_kind = _resolve_media_kind(url_text, kind=kind) - if resolved_kind == "image": - return Image.fromURL(url_text) - if resolved_kind in {"record", "audio"}: - return Record.fromURL(url_text) - if resolved_kind == "video": - return Video.fromURL(url_text) - if resolved_kind == "file": - return File(name=_filename_from_url(url_text), url=url_text) - raise AstrBotError.invalid_input( - f"Unsupported media kind: {kind}", - details={"kind": kind, "url": url_text}, - ) - - -def _filename_from_url(url: str) -> str: - name = Path(urlparse(url).path).name - return name or "download" - - -class BaseMessageComponent: - type: str = "unknown" - - def toDict(self) -> dict[str, Any]: - data: dict[str, Any] = {} - for key, value in self.__dict__.items(): - if key == "type" or value is None: - continue - data["type" if key == "_type" else key] = value - return {"type": str(self.type).lower(), "data": data} - - async def to_dict(self) -> dict[str, Any]: - return self.toDict() - - -class Plain(BaseMessageComponent): - type = "plain" - - def __init__(self, text: str, convert: bool = True, **_: Any) -> None: - self.text = text - self.convert = convert - - def toDict(self) -> dict[str, Any]: - return {"type": "text", "data": {"text": self.text.strip()}} - - async def to_dict(self) -> dict[str, Any]: - return {"type": "text", "data": {"text": self.text}} - - -class At(BaseMessageComponent): - type = "at" - - def __init__(self, qq: int | str, name: str | None = "", **_: Any) -> None: - self.qq = qq - self.name = name or "" - - def toDict(self) -> dict[str, Any]: - return {"type": "at", "data": {"qq": str(self.qq)}} - - -class AtAll(At): - def __init__(self, **_: Any) -> None: - super().__init__(qq="all") - - -class Reply(BaseMessageComponent): - type = "reply" - - def __init__(self, **kwargs: Any) -> None: - self.id = kwargs.get("id", "") - self.chain = _coerce_reply_chain(kwargs.get("chain", [])) - self.sender_id = kwargs.get("sender_id", 0) - self.sender_nickname = kwargs.get("sender_nickname", "") - self.time = kwargs.get("time", 0) - self.message_str = kwargs.get("message_str", "") - self.text = kwargs.get("text", "") - self.qq = kwargs.get("qq", 0) - self.seq = kwargs.get("seq", 0) - - def toDict(self) -> dict[str, Any]: - return { - "type": "reply", - "data": { - "id": self.id, - "chain": _reply_chain_payloads_sync(self.chain), - "sender_id": self.sender_id, - "sender_nickname": self.sender_nickname, - "time": self.time, - "message_str": self.message_str, - "text": self.text, - "qq": self.qq, - "seq": self.seq, - }, - } - - async def to_dict(self) -> dict[str, Any]: - return { - "type": "reply", - "data": { - "id": self.id, - "chain": await _reply_chain_payloads(self.chain), - "sender_id": self.sender_id, - "sender_nickname": self.sender_nickname, - "time": self.time, - "message_str": self.message_str, - "text": self.text, - "qq": self.qq, - "seq": self.seq, - }, - } - - -class Image(BaseMessageComponent): - type = "image" - - def __init__(self, file: str | None, **kwargs: Any) -> None: - self.file = file or "" - self._type = kwargs.get("_type", "") - self.subType = kwargs.get("subType", 0) - self.url = kwargs.get("url", "") - self.cache = kwargs.get("cache", True) - self.id = kwargs.get("id", 40000) - self.c = kwargs.get("c", 2) - self.path = kwargs.get("path", "") - self.file_unique = kwargs.get("file_unique", "") - - @staticmethod - def fromURL(url: str, **kwargs: Any) -> Image: - return Image(url, **kwargs) - - @staticmethod - def fromFileSystem(path: str, **kwargs: Any) -> Image: - return Image(f"file:///{os.path.abspath(path)}", path=path, **kwargs) - - @staticmethod - def fromBase64(base64_data: str, **kwargs: Any) -> Image: - return Image(f"base64://{base64_data}", **kwargs) - - async def convert_to_file_path(self) -> str: - url = self.url or self.file - if not url: - raise ValueError("No valid file or URL provided") - if url.startswith("file:///"): - return os.path.abspath(url[8:]) - if url.startswith(("http://", "https://")): - return await _download_to_temp_async(url, "imgseg", ".jpg") - if url.startswith("base64://"): - file_path = _temp_path("imgseg", ".jpg") - file_path.write_bytes(base64.b64decode(url.removeprefix("base64://"))) - return str(file_path.resolve()) - if os.path.exists(url): - return os.path.abspath(url) - raise ValueError(f"not a valid file: {url}") - - async def register_to_file_service(self) -> str: - return await _register_file_to_service(await self.convert_to_file_path()) - - -class Record(BaseMessageComponent): - type = "record" - - def __init__(self, file: str | None, **kwargs: Any) -> None: - self.file = file or "" - self.magic = kwargs.get("magic", False) - self.url = kwargs.get("url", "") - self.cache = kwargs.get("cache", True) - self.proxy = kwargs.get("proxy", True) - self.timeout = kwargs.get("timeout", 0) - self.text = kwargs.get("text") - self.path = kwargs.get("path") - - @staticmethod - def fromFileSystem(path: str, **kwargs: Any) -> Record: - return Record(f"file:///{os.path.abspath(path)}", path=path, **kwargs) - - @staticmethod - def fromURL(url: str, **kwargs: Any) -> Record: - return Record(url, **kwargs) - - async def convert_to_file_path(self) -> str: - if self.file.startswith("file:///"): - return os.path.abspath(self.file[8:]) - if self.file.startswith(("http://", "https://")): - return await _download_to_temp_async(self.file, "recordseg", ".dat") - if self.file.startswith("base64://"): - file_path = _temp_path("recordseg", ".dat") - file_path.write_bytes(base64.b64decode(self.file.removeprefix("base64://"))) - return str(file_path.resolve()) - if os.path.exists(self.file): - return os.path.abspath(self.file) - raise ValueError(f"not a valid file: {self.file}") - - async def register_to_file_service(self) -> str: - return await _register_file_to_service(await self.convert_to_file_path()) - - -class Video(BaseMessageComponent): - type = "video" - - def __init__(self, file: str, **kwargs: Any) -> None: - self.file = file - self.cover = kwargs.get("cover", "") - self.c = kwargs.get("c", 2) - self.path = kwargs.get("path", "") - - @staticmethod - def fromFileSystem(path: str, **kwargs: Any) -> Video: - return Video(f"file:///{os.path.abspath(path)}", path=path, **kwargs) - - @staticmethod - def fromURL(url: str, **kwargs: Any) -> Video: - return Video(url, **kwargs) - - async def convert_to_file_path(self) -> str: - if self.file.startswith("file:///"): - return os.path.abspath(self.file[8:]) - if self.file.startswith(("http://", "https://")): - return await _download_to_temp_async(self.file, "videoseg") - if os.path.exists(self.file): - return os.path.abspath(self.file) - raise ValueError(f"not a valid file: {self.file}") - - async def register_to_file_service(self) -> str: - return await _register_file_to_service(await self.convert_to_file_path()) - - -class File(BaseMessageComponent): - type = "file" - - def __init__(self, name: str, file: str = "", url: str = "") -> None: - self.name = name - self.file_ = file - self.url = url - - @property - def file(self) -> str: - return self.file_ - - @file.setter - def file(self, value: str) -> None: - if value.startswith(("http://", "https://")): - self.url = value - else: - self.file_ = value - - async def get_file(self, allow_return_url: bool = False) -> str: - if allow_return_url and self.url: - return self.url - if 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:] - if os.path.exists(path): - return os.path.abspath(path) - if self.url: - suffix = Path(urlparse(self.url).path).suffix - target = await _download_to_temp_async(self.url, "fileseg", suffix) - self.file_ = target - return target - return "" - - async def register_to_file_service(self) -> str: - return await _register_file_to_service(await self.get_file()) - - def toDict(self) -> dict[str, Any]: - payload_file = self.url or self.file_ - return { - "type": "file", - "data": { - "name": self.name, - "file": payload_file, - }, - } - - async def to_dict(self) -> dict[str, Any]: - payload_file = await self.get_file(allow_return_url=True) - return { - "type": "file", - "data": { - "name": self.name, - "file": payload_file, - }, - } - - -class Poke(BaseMessageComponent): - type = "poke" - - def __init__(self, poke_type: str | int | None = None, **kwargs: Any) -> None: - legacy_type = kwargs.pop("type", None) - if poke_type is None: - poke_type = legacy_type - if poke_type in (None, "", "poke", "Poke"): - poke_type = "126" - self._type = str(poke_type) - self.id = kwargs.get("id") - self.qq = kwargs.get("qq", 0) - - def target_id(self) -> str | None: - for value in (self.id, self.qq): - if value is None: - continue - text = str(value).strip() - if text and text != "0": - return text - return None - - def toDict(self) -> dict[str, Any]: - data = {"type": str(self._type or "126")} - target_id = self.target_id() - if target_id: - data["id"] = target_id - return {"type": "poke", "data": data} - - -class Forward(BaseMessageComponent): - type = "forward" - - def __init__(self, id: str, **_: Any) -> None: - self.id = id - - -class UnknownComponent(BaseMessageComponent): - type = "unknown" - - def __init__( - self, - *, - raw_type: str = "unknown", - raw_data: dict[str, Any] | None = None, - ) -> None: - self.raw_type = raw_type - self.raw_data = raw_data or {} - - def toDict(self) -> dict[str, Any]: - return { - "type": self.raw_type or "unknown", - "data": dict(self.raw_data), - } - - -def is_message_component(value: Any) -> bool: - return isinstance(value, BaseMessageComponent) - - -def payload_to_component(payload: Any) -> BaseMessageComponent: - if not isinstance(payload, dict): - return UnknownComponent(raw_data={"value": payload}) - - raw_type = str(payload.get("type", "unknown") or "unknown").lower() - data = payload.get("data") - if not isinstance(data, dict): - data = {} - - if raw_type in {"text", "plain"}: - return Plain(str(data.get("text", "")), convert=False) - if raw_type == "image": - return Image(str(data.get("file") or data.get("url") or "")) - if raw_type == "at": - qq_value = data.get("qq") - if str(qq_value).lower() == "all": - return AtAll() - qq = "" if qq_value is None else str(qq_value) - return At(qq=qq, name=str(data.get("name", ""))) - if raw_type == "reply": - return Reply(**data) - if raw_type == "record": - return Record(str(data.get("file") or data.get("url") or ""), **data) - if raw_type == "video": - return Video(str(data.get("file") or ""), **data) - if raw_type == "file": - file_value = str(data.get("file") or data.get("file_") or "") - if not file_value: - file_value = str(data.get("url") or "") - return File( - str(data.get("name", "")), - file="" if file_value.startswith(("http://", "https://")) else file_value, - url=file_value if file_value.startswith(("http://", "https://")) else "", - ) - if raw_type == "poke": - return Poke( - poke_type=data.get("type"), - id=data.get("id"), - qq=data.get("qq"), - ) - if raw_type == "forward": - return Forward(id=str(data.get("id", ""))) - - return UnknownComponent(raw_type=raw_type, raw_data=_stringify_mapping(data)) - - -def payloads_to_components(payloads: list[Any]) -> list[BaseMessageComponent]: - return [payload_to_component(item) for item in payloads] - - -def component_to_payload_sync(component: Any) -> dict[str, Any]: - if isinstance(component, UnknownComponent): - return component.toDict() - if isinstance(component, Plain): - return {"type": "text", "data": {"text": component.text}} - if _component_type_name(component) == "reply": - return { - "type": "reply", - "data": { - "id": getattr(component, "id", ""), - "chain": _reply_chain_payloads_sync(getattr(component, "chain", [])), - "sender_id": getattr(component, "sender_id", 0), - "sender_nickname": getattr(component, "sender_nickname", ""), - "time": getattr(component, "time", 0), - "message_str": getattr(component, "message_str", ""), - "text": getattr(component, "text", ""), - "qq": getattr(component, "qq", 0), - "seq": getattr(component, "seq", 0), - }, - } - to_dict = getattr(component, "toDict", None) - if callable(to_dict): - result = to_dict() - if isinstance(result, Mapping): - return _stringify_mapping(result) - return {"type": "unknown", "data": {"value": str(component)}} - - -async def component_to_payload(component: Any) -> dict[str, Any]: - if isinstance(component, (UnknownComponent, Plain)): - return component_to_payload_sync(component) - async_method = getattr(component, "to_dict", None) - if callable(async_method): - payload = async_method() - if inspect.isawaitable(payload): - result = await payload - if isinstance(result, dict): - return result - return component_to_payload_sync(component) - - -class MediaHelper: - @staticmethod - async def from_url( - url: str, - *, - kind: str = "auto", - ) -> BaseMessageComponent: - return build_media_component_from_url(url, kind=kind) - - @staticmethod - async def download(url: str, save_dir: Path) -> Path: - url_text = str(url).strip() - if not url_text: - raise AstrBotError.invalid_input( - "MediaHelper.download requires a non-empty url" - ) - parsed = urlparse(url_text) - if parsed.scheme not in {"http", "https"}: - raise AstrBotError.invalid_input( - "MediaHelper.download only supports http/https urls", - details={"url": url_text}, - ) - target_dir = Path(save_dir) - try: - target_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise AstrBotError.internal_error( - f"Failed to prepare download directory: {target_dir}", - details={"save_dir": str(target_dir)}, - ) from exc - target_path = target_dir / _filename_from_url(url_text) - try: - await asyncio.to_thread(urlretrieve, url_text, target_path) - except Exception as exc: - raise AstrBotError.network_error( - f"Failed to download media from '{url_text}'", - details={"url": url_text}, - ) from exc - return target_path.resolve() - - -__all__ = [ - "At", - "AtAll", - "BaseMessageComponent", - "File", - "Forward", - "Image", - "MediaHelper", - "Plain", - "Poke", - "Record", - "Reply", - "UnknownComponent", - "Video", - "component_to_payload", - "component_to_payload_sync", - "is_message_component", - "payload_to_component", - "payloads_to_components", -] diff --git a/astrbot-sdk/src/astrbot_sdk/message/result.py b/astrbot-sdk/src/astrbot_sdk/message/result.py deleted file mode 100644 index 3b32bac01..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message/result.py +++ /dev/null @@ -1,173 +0,0 @@ -"""SDK-local rich message result objects. - -本模块定义消息事件的结果对象,用于构建和返回富文本/多媒体消息。 - -核心类: -- MessageChain: 消息组件列表,支持同步/异步序列化为协议 payload -- MessageEventResult: 事件处理结果,包含类型标记和消息链 -- EventResultType: 结果类型枚举(EMPTY / CHAIN) - -辅助函数: -- coerce_message_chain: 将多种输入格式统一转换为 MessageChain, - 支持 MessageEventResult、MessageChain、单个组件或组件列表 -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any - -from .components import ( - At, - AtAll, - BaseMessageComponent, - File, - Plain, - Reply, - build_media_component_from_url, - component_to_payload, - component_to_payload_sync, - is_message_component, - payloads_to_components, -) - - -class EventResultType(str, Enum): - EMPTY = "empty" - CHAIN = "chain" - - -@dataclass(slots=True) -class MessageChain: - components: list[BaseMessageComponent] = field(default_factory=list) - - def append(self, component: BaseMessageComponent) -> MessageChain: - self.components.append(component) - return self - - def extend(self, components: list[BaseMessageComponent]) -> MessageChain: - self.components.extend(components) - return self - - def __iter__(self): - return iter(self.components) - - def __len__(self) -> int: - return len(self.components) - - def to_payload(self) -> list[dict[str, Any]]: - return [component_to_payload_sync(component) for component in self.components] - - async def to_payload_async(self) -> list[dict[str, Any]]: - return [await component_to_payload(component) for component in self.components] - - def get_plain_text(self, with_other_comps_mark: bool = False) -> str: - texts: list[str] = [] - for component in self.components: - if isinstance(component, Plain): - texts.append(component.text) - elif with_other_comps_mark: - texts.append(f"[{component.__class__.__name__}]") - return " ".join(texts) - - def plain_text(self, with_other_comps_mark: bool = False) -> str: - return self.get_plain_text(with_other_comps_mark=with_other_comps_mark) - - -@dataclass(slots=True) -class MessageEventResult: - type: EventResultType = EventResultType.EMPTY - chain: MessageChain = field(default_factory=MessageChain) - - def to_payload(self) -> dict[str, Any]: - return { - "type": self.type.value, - "chain": self.chain.to_payload(), - } - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> MessageEventResult: - result_type_raw = str(payload.get("type", EventResultType.EMPTY.value)) - try: - result_type = EventResultType(result_type_raw) - except ValueError: - result_type = EventResultType.EMPTY - chain_payload = payload.get("chain") - components = ( - payloads_to_components(chain_payload) - if isinstance(chain_payload, list) - else [] - ) - return cls(type=result_type, chain=MessageChain(components)) - - -@dataclass(slots=True) -class MessageBuilder: - components: list[BaseMessageComponent] = field(default_factory=list) - - def text(self, content: str) -> MessageBuilder: - self.components.append(Plain(content, convert=False)) - return self - - def at(self, user_id: str) -> MessageBuilder: - self.components.append(At(user_id)) - return self - - def at_all(self) -> MessageBuilder: - self.components.append(AtAll()) - return self - - def image(self, url: str) -> MessageBuilder: - self.components.append(build_media_component_from_url(url, kind="image")) - return self - - def record(self, url: str) -> MessageBuilder: - self.components.append(build_media_component_from_url(url, kind="record")) - return self - - def video(self, url: str) -> MessageBuilder: - self.components.append(build_media_component_from_url(url, kind="video")) - return self - - def file(self, name: str, *, file: str = "", url: str = "") -> MessageBuilder: - self.components.append(File(name=name, file=file, url=url)) - return self - - def reply(self, **kwargs: Any) -> MessageBuilder: - self.components.append(Reply(**kwargs)) - return self - - def append(self, component: BaseMessageComponent) -> MessageBuilder: - self.components.append(component) - return self - - def extend(self, components: list[BaseMessageComponent]) -> MessageBuilder: - self.components.extend(components) - return self - - def build(self) -> MessageChain: - return MessageChain(list(self.components)) - - -def coerce_message_chain(value: Any) -> MessageChain | None: - if isinstance(value, MessageEventResult): - return value.chain - if isinstance(value, MessageChain): - return value - if is_message_component(value): - return MessageChain([value]) - if isinstance(value, (list, tuple)) and all( - is_message_component(item) for item in value - ): - return MessageChain(list(value)) - return None - - -__all__ = [ - "EventResultType", - "MessageChain", - "MessageBuilder", - "MessageEventResult", - "coerce_message_chain", -] diff --git a/astrbot-sdk/src/astrbot_sdk/message/session.py b/astrbot-sdk/src/astrbot_sdk/message/session.py deleted file mode 100644 index a011f8dcc..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message/session.py +++ /dev/null @@ -1,46 +0,0 @@ -"""SDK-visible message session identifier. - -本模块定义 MessageSession 类,用于统一表示消息会话标识符。 -会话标识符格式为:platform_id:message_type:session_id - -例如: -- qq:group:123456 表示 QQ 群 123456 -- wechat:private:user789 表示微信私聊用户 user789 - -该格式与 AstrBot 核心的 unified_msg_origin 保持兼容, -确保 SDK 与核心之间的会话信息能够正确传递。 -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(slots=True) -class MessageSession: - """SDK-visible message session identifier. - - The string form stays compatible with AstrBot's unified message origin: - ``platform_id:message_type:session_id``. - """ - - platform_id: str - message_type: str - session_id: str - - def __post_init__(self) -> None: - self.platform_id = str(self.platform_id) - self.message_type = str(self.message_type).lower() - self.session_id = str(self.session_id) - - def __str__(self) -> str: - return f"{self.platform_id}:{self.message_type}:{self.session_id}" - - @classmethod - def from_str(cls, session: str) -> MessageSession: - platform_id, message_type, session_id = str(session).split(":", 2) - return cls( - platform_id=platform_id, - message_type=message_type, - session_id=session_id, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/message_components.py b/astrbot-sdk/src/astrbot_sdk/message_components.py deleted file mode 100644 index 372bd54a6..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message_components.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Backward-compatible alias for ``astrbot_sdk.message.components``. - -This module intentionally aliases the implementation module instead of re-exporting -names one by one so private helpers keep working with existing monkeypatch sites. -""" - -from __future__ import annotations - -import sys - -from .message import components as _components_module - -sys.modules[__name__] = _components_module diff --git a/astrbot-sdk/src/astrbot_sdk/message_result.py b/astrbot-sdk/src/astrbot_sdk/message_result.py deleted file mode 100644 index 0b575aad5..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message_result.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Backward-compatible alias for ``astrbot_sdk.message.result``. - -Use a module alias so callers patching helper functions on the legacy module path -still affect ``MessageBuilder`` and other implementation globals. -""" - -from __future__ import annotations - -import sys - -from .message import result as _result_module - -sys.modules[__name__] = _result_module diff --git a/astrbot-sdk/src/astrbot_sdk/message_session.py b/astrbot-sdk/src/astrbot_sdk/message_session.py deleted file mode 100644 index ec8725555..000000000 --- a/astrbot-sdk/src/astrbot_sdk/message_session.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Backward-compatible message session exports. - -The canonical implementation moved to ``astrbot_sdk.message.session``. Preserve the -legacy import path to avoid breaking existing plugins. -""" - -from .message.session import MessageSession - -__all__ = ["MessageSession"] diff --git a/astrbot-sdk/src/astrbot_sdk/plugin_kv.py b/astrbot-sdk/src/astrbot_sdk/plugin_kv.py deleted file mode 100644 index de1922b60..000000000 --- a/astrbot-sdk/src/astrbot_sdk/plugin_kv.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast - -if TYPE_CHECKING: - from .context import Context - -_VT = TypeVar("_VT") - - -class _HasRuntimeContext(Protocol): - def _require_runtime_context(self) -> Context: ... - - -class PluginKVStoreMixin: - """Plugin-scoped KV helpers backed by the runtime db client.""" - - def _runtime_context(self) -> Context: - owner = cast(_HasRuntimeContext, self) - return owner._require_runtime_context() - - @property - def plugin_id(self) -> str: - ctx = self._runtime_context() - return ctx.plugin_id - - async def put_kv_data(self, key: str, value: Any) -> None: - ctx = self._runtime_context() - await ctx.db.set(str(key), value) - - async def get_kv_data(self, key: str, default: _VT) -> _VT: - ctx = self._runtime_context() - value = await ctx.db.get(str(key)) - return default if value is None else value - - async def delete_kv_data(self, key: str) -> None: - ctx = self._runtime_context() - await ctx.db.delete(str(key)) diff --git a/astrbot-sdk/src/astrbot_sdk/protocol/__init__.py b/astrbot-sdk/src/astrbot_sdk/protocol/__init__.py deleted file mode 100644 index 6684d3070..000000000 --- a/astrbot-sdk/src/astrbot_sdk/protocol/__init__.py +++ /dev/null @@ -1,160 +0,0 @@ -"""AstrBot v4 协议公共入口。 - -这里暴露 v4 原生协议的消息模型、描述符和解析函数。 - -握手阶段由 `InitializeMessage` 发起,返回值不是另一条 initialize 消息,而是 -`ResultMessage(kind="initialize_result")`,其 `output` 负载可解析为 -`InitializeOutput`。 - -## 插件作者指南:什么时候用什么? - -### CapabilityDescriptor vs BUILTIN_CAPABILITY_SCHEMAS - -**CapabilityDescriptor** 用于**声明**能力: -- 当你的插件想**暴露**一个可被其他插件或核心调用的能力时 -- 例如:你的插件提供了一个翻译功能,想让其他插件调用 - - ```python - from astrbot_sdk.protocol import CapabilityDescriptor - - descriptor = CapabilityDescriptor( - name="my_plugin.translate", # 格式: 插件名.能力名 - description="翻译文本到指定语言", - input_schema={ - "type": "object", - "properties": { - "text": {"type": "string", "description": "要翻译的文本"}, - "target_lang": {"type": "string", "description": "目标语言"}, - }, - "required": ["text", "target_lang"], - }, - output_schema={ - "type": "object", - "properties": { - "translated": {"type": "string"}, - }, - }, - ) - ``` - -**BUILTIN_CAPABILITY_SCHEMAS** 用于**查询**内置能力的参数格式: -- 当你想**调用**核心提供的内置能力时,用它了解参数结构 -- 例如:你想调用 `llm.chat`,但不确定参数格式 - - ```python - from astrbot_sdk.protocol import BUILTIN_CAPABILITY_SCHEMAS - - # 查看 llm.chat 的输入参数格式 - schema = BUILTIN_CAPABILITY_SCHEMAS["llm.chat"] - print(schema["input"]) # 输入参数的 JSON Schema - print(schema["output"]) # 输出结果的 JSON Schema - ``` - -### 命名规范 - -能力名称必须遵循 `{namespace}.{action}` 或 `{namespace}.{sub_namespace}.{action}` 格式: -- `llm.chat` - LLM 对话 -- `db.set` - 数据库写入 -- `llm_tool.manager.activate` - LLM 工具管理 - -**保留命名空间**(插件不可使用): -- `handler.` - 处理器相关 -- `system.` - 系统内部能力 -- `internal.` - 内部实现细节 - -### 常用内置能力速查 - -| 能力名 | 用途 | -|-------|------| -| `llm.chat` | 同步 LLM 对话 | -| `llm.stream_chat` | 流式 LLM 对话 | -| `memory.save` / `memory.get` | 短期记忆存储 | -| `db.set` / `db.get` | 持久化键值存储 | -| `platform.send` | 发送消息 | -| `provider.get_using` | 获取当前 Provider | -""" - -from __future__ import annotations - -from typing import Any - -from . import _builtin_schemas as builtin_schemas -from .descriptors import ( # noqa: F401 - BUILTIN_CAPABILITY_SCHEMAS, - CapabilityDescriptor, - CommandRouteSpec, - CommandTrigger, - CompositeFilterSpec, - EventTrigger, - FilterSpec, - HandlerDescriptor, - LocalFilterRefSpec, - MessageTrigger, - MessageTypeFilterSpec, - ParamSpec, - Permissions, - PlatformFilterSpec, - ScheduleTrigger, - SessionRef, - Trigger, -) -from .messages import ( # noqa: F401 - CancelMessage, - ErrorPayload, - EventMessage, - InitializeMessage, - InitializeOutput, - InvokeMessage, - PeerInfo, - ProtocolMessage, - ResultMessage, - parse_message, -) - -_DIRECT_EXPORTS = [ - "BUILTIN_CAPABILITY_SCHEMAS", - "CapabilityDescriptor", - "CommandRouteSpec", - "CommandTrigger", - "CancelMessage", - "builtin_schemas", - "CompositeFilterSpec", - "ErrorPayload", - "EventTrigger", - "EventMessage", - "FilterSpec", - "HandlerDescriptor", - "InitializeMessage", - "InitializeOutput", - "InvokeMessage", - "LocalFilterRefSpec", - "MessageTrigger", - "MessageTypeFilterSpec", - "ParamSpec", - "PeerInfo", - "PlatformFilterSpec", - "Permissions", - "ProtocolMessage", - "ResultMessage", - "ScheduleTrigger", - "SessionRef", - "Trigger", - "parse_message", -] - -_BUILTIN_SCHEMA_EXPORTS = tuple( - name for name in builtin_schemas.__all__ if name != "BUILTIN_CAPABILITY_SCHEMAS" -) - - -def __getattr__(name: str) -> Any: - if name in _BUILTIN_SCHEMA_EXPORTS: - return getattr(builtin_schemas, name) - raise AttributeError(name) - - -def __dir__() -> list[str]: - return sorted(set(globals()) | set(_BUILTIN_SCHEMA_EXPORTS)) - - -__all__ = list(dict.fromkeys([*_DIRECT_EXPORTS, *_BUILTIN_SCHEMA_EXPORTS])) diff --git a/astrbot-sdk/src/astrbot_sdk/protocol/_builtin_schemas.py b/astrbot-sdk/src/astrbot_sdk/protocol/_builtin_schemas.py deleted file mode 100644 index 7035decfa..000000000 --- a/astrbot-sdk/src/astrbot_sdk/protocol/_builtin_schemas.py +++ /dev/null @@ -1,2357 +0,0 @@ -"""Builtin protocol schema constants. - -本模块定义了 AstrBot SDK v4 协议中所有内置能力的 JSON Schema。 -这些 Schema 用于: -1. 验证能力调用的输入参数是否符合预期格式 -2. 生成能力描述文档,供插件开发者参考 -3. 确保跨进程/跨语言调用时的类型安全 - -所有 Schema 遵循 JSON Schema 规范,支持基本类型检查、必填字段、数组元素约束等。 -""" - -from __future__ import annotations - -from typing import Any - -JSONSchema = dict[str, Any] - - -def _object_schema( - *, - required: tuple[str, ...] = (), - **properties: Any, -) -> JSONSchema: - return { - "type": "object", - "properties": properties, - "required": list(required), - } - - -def _nullable(schema: JSONSchema) -> JSONSchema: - return {"anyOf": [schema, {"type": "null"}]} - - -_OPTIONAL_CHAT_PROPERTIES: dict[str, Any] = { - "system": {"type": "string"}, - "history": {"type": "array", "items": {"type": "object"}}, - "contexts": {"type": "array", "items": {"type": "object"}}, - "provider_id": {"type": "string"}, - "tool_calls_result": {"type": "array", "items": {"type": "object"}}, - "model": {"type": "string"}, - "temperature": {"type": "number"}, - "image_urls": {"type": "array", "items": {"type": "string"}}, - "tools": {"type": "array"}, - "max_steps": {"type": "integer"}, -} - -LLM_CHAT_INPUT_SCHEMA = _object_schema( - required=("prompt",), - prompt={"type": "string"}, - **_OPTIONAL_CHAT_PROPERTIES, -) -LLM_CHAT_OUTPUT_SCHEMA = _object_schema(required=("text",), text={"type": "string"}) -LLM_CHAT_RAW_INPUT_SCHEMA = _object_schema( - required=("prompt",), - prompt={"type": "string"}, - **_OPTIONAL_CHAT_PROPERTIES, -) -LLM_CHAT_RAW_OUTPUT_SCHEMA = _object_schema( - required=("text",), - text={"type": "string"}, - usage=_nullable({"type": "object"}), - finish_reason=_nullable({"type": "string"}), - tool_calls={"type": "array", "items": {"type": "object"}}, - role=_nullable({"type": "string"}), - reasoning_content=_nullable({"type": "string"}), - reasoning_signature=_nullable({"type": "string"}), -) -LLM_STREAM_CHAT_INPUT_SCHEMA = _object_schema( - required=("prompt",), - prompt={"type": "string"}, - **_OPTIONAL_CHAT_PROPERTIES, -) -LLM_STREAM_CHAT_OUTPUT_SCHEMA = _object_schema( - required=("text",), text={"type": "string"} -) -MEMORY_SEARCH_INPUT_SCHEMA = _object_schema( - required=("query",), - query={"type": "string"}, - mode={"type": "string", "enum": ["auto", "keyword", "vector", "hybrid"]}, - limit={"type": "integer", "minimum": 1}, - min_score={"type": "number"}, - provider_id={"type": "string"}, - namespace={"type": "string"}, - include_descendants={"type": "boolean"}, -) -MEMORY_SEARCH_OUTPUT_SCHEMA = _object_schema( - required=("items",), - items={ - "type": "array", - "items": _object_schema( - required=("key", "value", "score", "match_type"), - key={"type": "string"}, - namespace=_nullable({"type": "string"}), - value=_nullable({"type": "object"}), - score={"type": "number"}, - match_type={ - "type": "string", - "enum": ["keyword", "vector", "hybrid"], - }, - ), - }, -) -MEMORY_SAVE_INPUT_SCHEMA = _object_schema( - required=("key", "value"), - key={"type": "string"}, - value={"type": "object"}, - namespace={"type": "string"}, -) -MEMORY_SAVE_OUTPUT_SCHEMA = _object_schema() -MEMORY_GET_INPUT_SCHEMA = _object_schema( - required=("key",), - key={"type": "string"}, - namespace={"type": "string"}, -) -MEMORY_GET_OUTPUT_SCHEMA = _object_schema( - required=("value",), - value=_nullable({"type": "object"}), -) -MEMORY_DELETE_INPUT_SCHEMA = _object_schema( - required=("key",), - key={"type": "string"}, - namespace={"type": "string"}, -) -MEMORY_DELETE_OUTPUT_SCHEMA = _object_schema() -MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA = _object_schema( - required=("key", "value", "ttl_seconds"), - key={"type": "string"}, - value={"type": "object"}, - ttl_seconds={"type": "integer", "minimum": 1}, - namespace={"type": "string"}, -) -MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA = _object_schema() -MEMORY_GET_MANY_INPUT_SCHEMA = _object_schema( - required=("keys",), - keys={"type": "array", "items": {"type": "string"}}, - namespace={"type": "string"}, -) -MEMORY_GET_MANY_OUTPUT_SCHEMA = _object_schema( - required=("items",), - items={ - "type": "array", - "items": _object_schema( - required=("key", "value"), - key={"type": "string"}, - value=_nullable({"type": "object"}), - ), - }, -) -MEMORY_DELETE_MANY_INPUT_SCHEMA = _object_schema( - required=("keys",), - keys={"type": "array", "items": {"type": "string"}}, - namespace={"type": "string"}, -) -MEMORY_DELETE_MANY_OUTPUT_SCHEMA = _object_schema( - required=("deleted_count",), - deleted_count={"type": "integer"}, -) -MEMORY_STATS_INPUT_SCHEMA = _object_schema( - namespace={"type": "string"}, - include_descendants={"type": "boolean"}, -) -MEMORY_STATS_OUTPUT_SCHEMA = _object_schema( - total_items={"type": "integer"}, - total_bytes=_nullable({"type": "integer"}), - plugin_id=_nullable({"type": "string"}), - ttl_entries=_nullable({"type": "integer"}), - namespace=_nullable({"type": "string"}), - namespace_count=_nullable({"type": "integer"}), - indexed_items=_nullable({"type": "integer"}), - embedded_items=_nullable({"type": "integer"}), - dirty_items=_nullable({"type": "integer"}), - fts_enabled={"type": "boolean"}, - vector_backend=_nullable({"type": "string"}), - vector_indexes={"type": "array", "items": {"type": "object"}}, -) -SYSTEM_GET_DATA_DIR_INPUT_SCHEMA = _object_schema() -SYSTEM_GET_DATA_DIR_OUTPUT_SCHEMA = _object_schema( - required=("path",), - path={"type": "string"}, -) -SYSTEM_TEXT_TO_IMAGE_INPUT_SCHEMA = _object_schema( - required=("text",), - text={"type": "string"}, - return_url={"type": "boolean"}, -) -SYSTEM_TEXT_TO_IMAGE_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result={"type": "string"}, -) -SYSTEM_HTML_RENDER_INPUT_SCHEMA = _object_schema( - required=("tmpl", "data"), - tmpl={"type": "string"}, - data={"type": "object"}, - return_url={"type": "boolean"}, - options=_nullable({"type": "object"}), -) -SYSTEM_HTML_RENDER_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result={"type": "string"}, -) -SYSTEM_FILE_REGISTER_INPUT_SCHEMA = _object_schema( - required=("path",), - path={"type": "string"}, - timeout=_nullable({"type": "number"}), -) -SYSTEM_FILE_REGISTER_OUTPUT_SCHEMA = _object_schema( - required=("token", "url"), - token={"type": "string"}, - url={"type": "string"}, -) -SYSTEM_FILE_HANDLE_INPUT_SCHEMA = _object_schema( - required=("token",), - token={"type": "string"}, -) -SYSTEM_FILE_HANDLE_OUTPUT_SCHEMA = _object_schema( - required=("path",), - path={"type": "string"}, -) -SYSTEM_SESSION_WAITER_REGISTER_INPUT_SCHEMA = _object_schema( - required=("session_key",), - session_key={"type": "string"}, -) -SYSTEM_SESSION_WAITER_REGISTER_OUTPUT_SCHEMA = _object_schema() -SYSTEM_SESSION_WAITER_UNREGISTER_INPUT_SCHEMA = _object_schema( - required=("session_key",), - session_key={"type": "string"}, -) -SYSTEM_SESSION_WAITER_UNREGISTER_OUTPUT_SCHEMA = _object_schema() -DB_GET_INPUT_SCHEMA = _object_schema(required=("key",), key={"type": "string"}) -DB_GET_OUTPUT_SCHEMA = _object_schema( - required=("value",), - value=_nullable({}), -) -DB_SET_INPUT_SCHEMA = _object_schema( - required=("key", "value"), - key={"type": "string"}, - value={}, -) -DB_SET_OUTPUT_SCHEMA = _object_schema() -DB_DELETE_INPUT_SCHEMA = _object_schema(required=("key",), key={"type": "string"}) -DB_DELETE_OUTPUT_SCHEMA = _object_schema() -DB_LIST_INPUT_SCHEMA = _object_schema(prefix=_nullable({"type": "string"})) -DB_LIST_OUTPUT_SCHEMA = _object_schema( - required=("keys",), - keys={"type": "array", "items": {"type": "string"}}, -) -DB_GET_MANY_INPUT_SCHEMA = _object_schema( - required=("keys",), - keys={"type": "array", "items": {"type": "string"}}, -) -DB_GET_MANY_OUTPUT_SCHEMA = _object_schema( - required=("items",), - items={ - "type": "array", - "items": _object_schema( - required=("key", "value"), - key={"type": "string"}, - value=_nullable({}), - ), - }, -) -DB_SET_MANY_INPUT_SCHEMA = _object_schema( - required=("items",), - items={ - "type": "array", - "items": _object_schema( - required=("key", "value"), - key={"type": "string"}, - value={}, - ), - }, -) -DB_SET_MANY_OUTPUT_SCHEMA = _object_schema() -DB_WATCH_INPUT_SCHEMA = _object_schema(prefix=_nullable({"type": "string"})) -DB_WATCH_OUTPUT_SCHEMA = _object_schema() -SESSION_REF_SCHEMA = _object_schema( - required=("conversation_id",), - conversation_id={"type": "string"}, - platform=_nullable({"type": "string"}), - raw=_nullable({"type": "object"}), -) -SYSTEM_EVENT_REACT_INPUT_SCHEMA = _object_schema( - required=("emoji",), - target=_nullable(SESSION_REF_SCHEMA), - emoji={"type": "string"}, -) -SYSTEM_EVENT_REACT_OUTPUT_SCHEMA = _object_schema( - required=("supported",), - supported={"type": "boolean"}, -) -SYSTEM_EVENT_SEND_TYPING_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_SEND_TYPING_OUTPUT_SCHEMA = _object_schema( - required=("supported",), - supported={"type": "boolean"}, -) -SYSTEM_EVENT_SEND_STREAMING_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), - use_fallback={"type": "boolean"}, -) -SYSTEM_EVENT_SEND_STREAMING_OUTPUT_SCHEMA = _object_schema( - required=("supported",), - supported={"type": "boolean"}, - stream_id=_nullable({"type": "string"}), -) -SYSTEM_EVENT_SEND_STREAMING_CHUNK_INPUT_SCHEMA = _object_schema( - required=("stream_id", "chain"), - stream_id={"type": "string"}, - chain={"type": "array", "items": {"type": "object"}}, -) -SYSTEM_EVENT_SEND_STREAMING_CHUNK_OUTPUT_SCHEMA = _object_schema() -SYSTEM_EVENT_SEND_STREAMING_CLOSE_INPUT_SCHEMA = _object_schema( - required=("stream_id",), - stream_id={"type": "string"}, -) -SYSTEM_EVENT_SEND_STREAMING_CLOSE_OUTPUT_SCHEMA = _object_schema( - required=("supported",), - supported={"type": "boolean"}, -) -SYSTEM_EVENT_LLM_GET_STATE_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_LLM_GET_STATE_OUTPUT_SCHEMA = _object_schema( - required=("should_call_llm", "requested_llm"), - should_call_llm={"type": "boolean"}, - requested_llm={"type": "boolean"}, -) -SYSTEM_EVENT_LLM_REQUEST_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_LLM_REQUEST_OUTPUT_SCHEMA = _object_schema( - required=("should_call_llm", "requested_llm"), - should_call_llm={"type": "boolean"}, - requested_llm={"type": "boolean"}, -) -SYSTEM_EVENT_RESULT_GET_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_RESULT_GET_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result=_nullable({"type": "object"}), -) -SYSTEM_EVENT_RESULT_SET_INPUT_SCHEMA = _object_schema( - required=("result",), - target=_nullable(SESSION_REF_SCHEMA), - result={"type": "object"}, -) -SYSTEM_EVENT_RESULT_SET_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result={"type": "object"}, -) -SYSTEM_EVENT_RESULT_CLEAR_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_RESULT_CLEAR_OUTPUT_SCHEMA = _object_schema() -SYSTEM_EVENT_HANDLER_WHITELIST_GET_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), -) -SYSTEM_EVENT_HANDLER_WHITELIST_GET_OUTPUT_SCHEMA = _object_schema( - required=("plugin_names",), - plugin_names=_nullable({"type": "array", "items": {"type": "string"}}), -) -SYSTEM_EVENT_HANDLER_WHITELIST_SET_INPUT_SCHEMA = _object_schema( - target=_nullable(SESSION_REF_SCHEMA), - plugin_names=_nullable({"type": "array", "items": {"type": "string"}}), -) -SYSTEM_EVENT_HANDLER_WHITELIST_SET_OUTPUT_SCHEMA = _object_schema( - required=("plugin_names",), - plugin_names=_nullable({"type": "array", "items": {"type": "string"}}), -) -PLATFORM_SEND_INPUT_SCHEMA = _object_schema( - required=("session", "text"), - session={"type": "string"}, - target=_nullable(SESSION_REF_SCHEMA), - text={"type": "string"}, -) -PLATFORM_SEND_OUTPUT_SCHEMA = _object_schema( - required=("message_id",), - message_id={"type": "string"}, -) -PLATFORM_SEND_IMAGE_INPUT_SCHEMA = _object_schema( - required=("session", "image_url"), - session={"type": "string"}, - target=_nullable(SESSION_REF_SCHEMA), - image_url={"type": "string"}, -) -PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA = _object_schema( - required=("message_id",), - message_id={"type": "string"}, -) -PLATFORM_SEND_CHAIN_INPUT_SCHEMA = _object_schema( - required=("session", "chain"), - session={"type": "string"}, - target=_nullable(SESSION_REF_SCHEMA), - chain={"type": "array", "items": {"type": "object"}}, -) -PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA = _object_schema( - required=("message_id",), - message_id={"type": "string"}, -) -PLATFORM_SEND_BY_SESSION_INPUT_SCHEMA = _object_schema( - required=("session", "chain"), - session={"type": "string"}, - chain={"type": "array", "items": {"type": "object"}}, -) -PLATFORM_SEND_BY_SESSION_OUTPUT_SCHEMA = _object_schema( - required=("message_id",), - message_id={"type": "string"}, -) -PLATFORM_GET_GROUP_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - target=_nullable(SESSION_REF_SCHEMA), -) -PLATFORM_GET_GROUP_OUTPUT_SCHEMA = _object_schema( - required=("group",), - group=_nullable({"type": "object"}), -) -PLATFORM_GET_MEMBERS_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - target=_nullable(SESSION_REF_SCHEMA), -) -PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA = _object_schema( - required=("members",), - members={"type": "array", "items": {"type": "object"}}, -) -PLATFORM_INSTANCE_SCHEMA = _object_schema( - required=("id", "name", "type", "status"), - id={"type": "string"}, - name={"type": "string"}, - type={"type": "string"}, - status={"type": "string"}, -) -PLATFORM_LIST_INSTANCES_INPUT_SCHEMA = _object_schema() -PLATFORM_LIST_INSTANCES_OUTPUT_SCHEMA = _object_schema( - required=("platforms",), - platforms={"type": "array", "items": PLATFORM_INSTANCE_SCHEMA}, -) -PLATFORM_ERROR_SCHEMA = _object_schema( - required=("message", "timestamp"), - message={"type": "string"}, - timestamp={"type": "string"}, - traceback=_nullable({"type": "string"}), -) -PLATFORM_MANAGER_STATE_SCHEMA = _object_schema( - required=("id", "name", "type", "status", "errors", "unified_webhook"), - id={"type": "string"}, - name={"type": "string"}, - type={"type": "string"}, - status={"type": "string"}, - errors={"type": "array", "items": PLATFORM_ERROR_SCHEMA}, - last_error=_nullable(PLATFORM_ERROR_SCHEMA), - unified_webhook={"type": "boolean"}, -) -PLATFORM_STATS_SCHEMA = _object_schema( - required=( - "id", - "type", - "display_name", - "status", - "error_count", - "unified_webhook", - ), - id={"type": "string"}, - type={"type": "string"}, - display_name={"type": "string"}, - status={"type": "string"}, - started_at=_nullable({"type": "string"}), - error_count={"type": "integer"}, - last_error=_nullable(PLATFORM_ERROR_SCHEMA), - unified_webhook={"type": "boolean"}, - meta={"type": "object"}, -) -PLATFORM_MANAGER_GET_BY_ID_INPUT_SCHEMA = _object_schema( - required=("platform_id",), - platform_id={"type": "string"}, -) -PLATFORM_MANAGER_GET_BY_ID_OUTPUT_SCHEMA = _object_schema( - required=("platform",), - platform=_nullable(PLATFORM_MANAGER_STATE_SCHEMA), -) -PLATFORM_MANAGER_CLEAR_ERRORS_INPUT_SCHEMA = _object_schema( - required=("platform_id",), - platform_id={"type": "string"}, -) -PLATFORM_MANAGER_CLEAR_ERRORS_OUTPUT_SCHEMA = _object_schema() -PLATFORM_MANAGER_GET_STATS_INPUT_SCHEMA = _object_schema( - required=("platform_id",), - platform_id={"type": "string"}, -) -PLATFORM_MANAGER_GET_STATS_OUTPUT_SCHEMA = _object_schema( - required=("stats",), - stats=_nullable(PLATFORM_STATS_SCHEMA), -) -SESSION_PLUGIN_IS_ENABLED_INPUT_SCHEMA = _object_schema( - required=("session", "plugin_name"), - session={"type": "string"}, - plugin_name={"type": "string"}, -) -SESSION_PLUGIN_IS_ENABLED_OUTPUT_SCHEMA = _object_schema( - required=("enabled",), - enabled={"type": "boolean"}, -) -SESSION_PLUGIN_FILTER_HANDLERS_INPUT_SCHEMA = _object_schema( - required=("session", "handlers"), - session={"type": "string"}, - handlers={"type": "array", "items": {"type": "object"}}, -) -SESSION_PLUGIN_FILTER_HANDLERS_OUTPUT_SCHEMA = _object_schema( - required=("handlers",), - handlers={"type": "array", "items": {"type": "object"}}, -) -SESSION_SERVICE_IS_LLM_ENABLED_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, -) -SESSION_SERVICE_IS_LLM_ENABLED_OUTPUT_SCHEMA = _object_schema( - required=("enabled",), - enabled={"type": "boolean"}, -) -SESSION_SERVICE_SET_LLM_STATUS_INPUT_SCHEMA = _object_schema( - required=("session", "enabled"), - session={"type": "string"}, - enabled={"type": "boolean"}, -) -SESSION_SERVICE_SET_LLM_STATUS_OUTPUT_SCHEMA = _object_schema() -SESSION_SERVICE_IS_TTS_ENABLED_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, -) -SESSION_SERVICE_IS_TTS_ENABLED_OUTPUT_SCHEMA = _object_schema( - required=("enabled",), - enabled={"type": "boolean"}, -) -SESSION_SERVICE_SET_TTS_STATUS_INPUT_SCHEMA = _object_schema( - required=("session", "enabled"), - session={"type": "string"}, - enabled={"type": "boolean"}, -) -SESSION_SERVICE_SET_TTS_STATUS_OUTPUT_SCHEMA = _object_schema() -PERSONA_RECORD_SCHEMA = _object_schema( - required=("persona_id", "system_prompt", "begin_dialogs", "sort_order"), - persona_id={"type": "string"}, - system_prompt={"type": "string"}, - begin_dialogs={"type": "array", "items": {"type": "string"}}, - tools=_nullable({"type": "array", "items": {"type": "string"}}), - skills=_nullable({"type": "array", "items": {"type": "string"}}), - custom_error_message=_nullable({"type": "string"}), - folder_id=_nullable({"type": "string"}), - sort_order={"type": "integer"}, - created_at=_nullable({"type": "string"}), - updated_at=_nullable({"type": "string"}), -) -PERSONA_CREATE_SCHEMA = _object_schema( - required=("persona_id", "system_prompt"), - persona_id={"type": "string"}, - system_prompt={"type": "string"}, - begin_dialogs={"type": "array", "items": {"type": "string"}}, - tools=_nullable({"type": "array", "items": {"type": "string"}}), - skills=_nullable({"type": "array", "items": {"type": "string"}}), - custom_error_message=_nullable({"type": "string"}), - folder_id=_nullable({"type": "string"}), - sort_order={"type": "integer"}, -) -PERSONA_UPDATE_SCHEMA = _object_schema( - system_prompt=_nullable({"type": "string"}), - begin_dialogs=_nullable({"type": "array", "items": {"type": "string"}}), - tools=_nullable({"type": "array", "items": {"type": "string"}}), - skills=_nullable({"type": "array", "items": {"type": "string"}}), - custom_error_message=_nullable({"type": "string"}), -) -PERSONA_GET_INPUT_SCHEMA = _object_schema( - required=("persona_id",), - persona_id={"type": "string"}, -) -PERSONA_GET_OUTPUT_SCHEMA = _object_schema( - required=("persona",), - persona=PERSONA_RECORD_SCHEMA, -) -PERSONA_LIST_INPUT_SCHEMA = _object_schema() -PERSONA_LIST_OUTPUT_SCHEMA = _object_schema( - required=("personas",), - personas={"type": "array", "items": PERSONA_RECORD_SCHEMA}, -) -PERSONA_CREATE_INPUT_SCHEMA = _object_schema( - required=("persona",), - persona=PERSONA_CREATE_SCHEMA, -) -PERSONA_CREATE_OUTPUT_SCHEMA = _object_schema( - required=("persona",), - persona=PERSONA_RECORD_SCHEMA, -) -PERSONA_UPDATE_INPUT_SCHEMA = _object_schema( - required=("persona_id", "persona"), - persona_id={"type": "string"}, - persona=PERSONA_UPDATE_SCHEMA, -) -PERSONA_UPDATE_OUTPUT_SCHEMA = _object_schema( - required=("persona",), - persona=_nullable(PERSONA_RECORD_SCHEMA), -) -PERSONA_DELETE_INPUT_SCHEMA = _object_schema( - required=("persona_id",), - persona_id={"type": "string"}, -) -PERSONA_DELETE_OUTPUT_SCHEMA = _object_schema() -CONVERSATION_RECORD_SCHEMA = _object_schema( - required=("conversation_id", "session", "platform_id", "history"), - conversation_id={"type": "string"}, - session={"type": "string"}, - platform_id={"type": "string"}, - history={"type": "array", "items": {"type": "object"}}, - title=_nullable({"type": "string"}), - persona_id=_nullable({"type": "string"}), - created_at=_nullable({"type": "string"}), - updated_at=_nullable({"type": "string"}), - token_usage=_nullable({"type": "integer"}), -) -CONVERSATION_CREATE_SCHEMA = _object_schema( - platform_id=_nullable({"type": "string"}), - history=_nullable({"type": "array", "items": {"type": "object"}}), - title=_nullable({"type": "string"}), - persona_id=_nullable({"type": "string"}), -) -CONVERSATION_UPDATE_SCHEMA = _object_schema( - history=_nullable({"type": "array", "items": {"type": "object"}}), - title=_nullable({"type": "string"}), - persona_id=_nullable({"type": "string"}), - token_usage=_nullable({"type": "integer"}), -) -CONVERSATION_NEW_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - conversation=_nullable(CONVERSATION_CREATE_SCHEMA), -) -CONVERSATION_NEW_OUTPUT_SCHEMA = _object_schema( - required=("conversation_id",), - conversation_id={"type": "string"}, -) -CONVERSATION_SWITCH_INPUT_SCHEMA = _object_schema( - required=("session", "conversation_id"), - session={"type": "string"}, - conversation_id={"type": "string"}, -) -CONVERSATION_SWITCH_OUTPUT_SCHEMA = _object_schema() -CONVERSATION_DELETE_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - conversation_id=_nullable({"type": "string"}), -) -CONVERSATION_DELETE_OUTPUT_SCHEMA = _object_schema() -CONVERSATION_GET_INPUT_SCHEMA = _object_schema( - required=("session", "conversation_id"), - session={"type": "string"}, - conversation_id={"type": "string"}, - create_if_not_exists={"type": "boolean"}, -) -CONVERSATION_GET_OUTPUT_SCHEMA = _object_schema( - required=("conversation",), - conversation=_nullable(CONVERSATION_RECORD_SCHEMA), -) -CONVERSATION_GET_CURRENT_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - create_if_not_exists={"type": "boolean"}, -) -CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA = _object_schema( - required=("conversation",), - conversation=_nullable(CONVERSATION_RECORD_SCHEMA), -) -CONVERSATION_LIST_INPUT_SCHEMA = _object_schema( - session=_nullable({"type": "string"}), - platform_id=_nullable({"type": "string"}), -) -CONVERSATION_LIST_OUTPUT_SCHEMA = _object_schema( - required=("conversations",), - conversations={"type": "array", "items": CONVERSATION_RECORD_SCHEMA}, -) -CONVERSATION_UPDATE_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - conversation_id=_nullable({"type": "string"}), - conversation=_nullable(CONVERSATION_UPDATE_SCHEMA), -) -CONVERSATION_UPDATE_OUTPUT_SCHEMA = _object_schema() -CONVERSATION_UNSET_PERSONA_INPUT_SCHEMA = _object_schema( - required=("session",), - session={"type": "string"}, - conversation_id=_nullable({"type": "string"}), -) -CONVERSATION_UNSET_PERSONA_OUTPUT_SCHEMA = _object_schema() -MESSAGE_HISTORY_SESSION_SCHEMA = _object_schema( - required=("platform_id", "message_type", "session_id"), - platform_id={"type": "string"}, - message_type={"type": "string", "enum": ["group", "private", "other"]}, - session_id={"type": "string"}, -) -MESSAGE_HISTORY_SENDER_SCHEMA = _object_schema( - sender_id=_nullable({"type": "string"}), - sender_name=_nullable({"type": "string"}), -) -MESSAGE_HISTORY_RECORD_SCHEMA = _object_schema( - required=("id", "session", "sender", "parts", "metadata"), - id={"type": "integer"}, - session=MESSAGE_HISTORY_SESSION_SCHEMA, - sender=MESSAGE_HISTORY_SENDER_SCHEMA, - parts={"type": "array", "items": {"type": "object"}}, - metadata={"type": "object"}, - created_at=_nullable({"type": "string"}), - updated_at=_nullable({"type": "string"}), - idempotency_key=_nullable({"type": "string"}), -) -MESSAGE_HISTORY_PAGE_SCHEMA = _object_schema( - required=("records",), - records={"type": "array", "items": MESSAGE_HISTORY_RECORD_SCHEMA}, - next_cursor=_nullable({"type": "string"}), - total=_nullable({"type": "integer"}), -) -MESSAGE_HISTORY_LIST_INPUT_SCHEMA = _object_schema( - required=("session",), - session=MESSAGE_HISTORY_SESSION_SCHEMA, - cursor=_nullable({"type": "string"}), - limit={"type": "integer", "minimum": 1}, -) -MESSAGE_HISTORY_LIST_OUTPUT_SCHEMA = _object_schema( - required=("page",), - page=MESSAGE_HISTORY_PAGE_SCHEMA, -) -MESSAGE_HISTORY_GET_BY_ID_INPUT_SCHEMA = _object_schema( - required=("session", "record_id"), - session=MESSAGE_HISTORY_SESSION_SCHEMA, - record_id={"type": "integer", "minimum": 1}, -) -MESSAGE_HISTORY_GET_BY_ID_OUTPUT_SCHEMA = _object_schema( - required=("record",), - record=_nullable(MESSAGE_HISTORY_RECORD_SCHEMA), -) -MESSAGE_HISTORY_APPEND_INPUT_SCHEMA = _object_schema( - required=("session", "sender", "parts"), - session=MESSAGE_HISTORY_SESSION_SCHEMA, - sender=MESSAGE_HISTORY_SENDER_SCHEMA, - parts={"type": "array", "items": {"type": "object"}}, - metadata=_nullable({"type": "object"}), - idempotency_key=_nullable({"type": "string"}), -) -MESSAGE_HISTORY_APPEND_OUTPUT_SCHEMA = _object_schema( - required=("record",), - record=MESSAGE_HISTORY_RECORD_SCHEMA, -) -MESSAGE_HISTORY_DELETE_BEFORE_INPUT_SCHEMA = _object_schema( - required=("session", "before"), - session=MESSAGE_HISTORY_SESSION_SCHEMA, - before={"type": "string"}, -) -MESSAGE_HISTORY_DELETE_BEFORE_OUTPUT_SCHEMA = _object_schema( - required=("deleted_count",), - deleted_count={"type": "integer"}, -) -MESSAGE_HISTORY_DELETE_AFTER_INPUT_SCHEMA = _object_schema( - required=("session", "after"), - session=MESSAGE_HISTORY_SESSION_SCHEMA, - after={"type": "string"}, -) -MESSAGE_HISTORY_DELETE_AFTER_OUTPUT_SCHEMA = _object_schema( - required=("deleted_count",), - deleted_count={"type": "integer"}, -) -MESSAGE_HISTORY_DELETE_ALL_INPUT_SCHEMA = _object_schema( - required=("session",), - session=MESSAGE_HISTORY_SESSION_SCHEMA, -) -MESSAGE_HISTORY_DELETE_ALL_OUTPUT_SCHEMA = _object_schema( - required=("deleted_count",), - deleted_count={"type": "integer"}, -) -MCP_SERVER_SCOPE_SCHEMA = {"type": "string", "enum": ["local", "global"]} -MCP_SERVER_RECORD_SCHEMA = _object_schema( - required=("name", "scope", "active", "running", "config", "tools", "errlogs"), - name={"type": "string"}, - scope=MCP_SERVER_SCOPE_SCHEMA, - active={"type": "boolean"}, - running={"type": "boolean"}, - config={"type": "object"}, - tools={"type": "array", "items": {"type": "string"}}, - errlogs={"type": "array", "items": {"type": "string"}}, - last_error=_nullable({"type": "string"}), -) -MCP_LOCAL_GET_INPUT_SCHEMA = _object_schema(required=("name",), name={"type": "string"}) -MCP_LOCAL_GET_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=_nullable(MCP_SERVER_RECORD_SCHEMA), -) -MCP_LOCAL_LIST_INPUT_SCHEMA = _object_schema() -MCP_LOCAL_LIST_OUTPUT_SCHEMA = _object_schema( - required=("servers",), - servers={"type": "array", "items": MCP_SERVER_RECORD_SCHEMA}, -) -MCP_LOCAL_ENABLE_INPUT_SCHEMA = _object_schema( - required=("name",), name={"type": "string"} -) -MCP_LOCAL_ENABLE_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_LOCAL_DISABLE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -MCP_LOCAL_DISABLE_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_LOCAL_WAIT_UNTIL_READY_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, - timeout={"type": "number"}, -) -MCP_LOCAL_WAIT_UNTIL_READY_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_SESSION_OPEN_INPUT_SCHEMA = _object_schema( - required=("name", "config"), - name={"type": "string"}, - config={"type": "object"}, - timeout={"type": "number"}, -) -MCP_SESSION_OPEN_OUTPUT_SCHEMA = _object_schema( - required=("session_id", "tools"), - session_id={"type": "string"}, - tools={"type": "array", "items": {"type": "string"}}, -) -MCP_SESSION_LIST_TOOLS_INPUT_SCHEMA = _object_schema( - required=("session_id",), - session_id={"type": "string"}, -) -MCP_SESSION_LIST_TOOLS_OUTPUT_SCHEMA = _object_schema( - required=("tools",), - tools={"type": "array", "items": {"type": "string"}}, -) -MCP_SESSION_CALL_TOOL_INPUT_SCHEMA = _object_schema( - required=("session_id", "tool_name", "args"), - session_id={"type": "string"}, - tool_name={"type": "string"}, - args={"type": "object"}, -) -MCP_SESSION_CALL_TOOL_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result={"type": "object"}, -) -MCP_SESSION_CLOSE_INPUT_SCHEMA = _object_schema( - required=("session_id",), - session_id={"type": "string"}, -) -MCP_SESSION_CLOSE_OUTPUT_SCHEMA = _object_schema() -MCP_GLOBAL_REGISTER_INPUT_SCHEMA = _object_schema( - required=("name", "config"), - name={"type": "string"}, - config={"type": "object"}, - timeout={"type": "number"}, -) -MCP_GLOBAL_REGISTER_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_GLOBAL_GET_INPUT_SCHEMA = _object_schema( - required=("name",), name={"type": "string"} -) -MCP_GLOBAL_GET_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=_nullable(MCP_SERVER_RECORD_SCHEMA), -) -MCP_GLOBAL_LIST_INPUT_SCHEMA = _object_schema() -MCP_GLOBAL_LIST_OUTPUT_SCHEMA = _object_schema( - required=("servers",), - servers={"type": "array", "items": MCP_SERVER_RECORD_SCHEMA}, -) -MCP_GLOBAL_ENABLE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, - timeout={"type": "number"}, -) -MCP_GLOBAL_ENABLE_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_GLOBAL_DISABLE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -MCP_GLOBAL_DISABLE_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -MCP_GLOBAL_UNREGISTER_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -MCP_GLOBAL_UNREGISTER_OUTPUT_SCHEMA = _object_schema( - required=("server",), - server=MCP_SERVER_RECORD_SCHEMA, -) -INTERNAL_MCP_LOCAL_EXECUTE_INPUT_SCHEMA = _object_schema( - required=("plugin_id", "server_name", "tool_name", "tool_args"), - plugin_id={"type": "string"}, - server_name={"type": "string"}, - tool_name={"type": "string"}, - tool_args={"type": "object"}, -) -INTERNAL_MCP_LOCAL_EXECUTE_OUTPUT_SCHEMA = _object_schema( - required=("content", "success"), - content=_nullable({"type": "string"}), - success={"type": "boolean"}, -) -KNOWLEDGE_BASE_RECORD_SCHEMA = _object_schema( - required=("kb_id", "kb_name", "embedding_provider_id", "doc_count", "chunk_count"), - kb_id={"type": "string"}, - kb_name={"type": "string"}, - description=_nullable({"type": "string"}), - emoji=_nullable({"type": "string"}), - embedding_provider_id={"type": "string"}, - rerank_provider_id=_nullable({"type": "string"}), - chunk_size=_nullable({"type": "integer"}), - chunk_overlap=_nullable({"type": "integer"}), - top_k_dense=_nullable({"type": "integer"}), - top_k_sparse=_nullable({"type": "integer"}), - top_m_final=_nullable({"type": "integer"}), - doc_count={"type": "integer"}, - chunk_count={"type": "integer"}, - created_at=_nullable({"type": "string"}), - updated_at=_nullable({"type": "string"}), -) -KNOWLEDGE_BASE_CREATE_SCHEMA = _object_schema( - required=("kb_name", "embedding_provider_id"), - kb_name={"type": "string"}, - embedding_provider_id={"type": "string"}, - description=_nullable({"type": "string"}), - emoji=_nullable({"type": "string"}), - rerank_provider_id=_nullable({"type": "string"}), - chunk_size=_nullable({"type": "integer"}), - chunk_overlap=_nullable({"type": "integer"}), - top_k_dense=_nullable({"type": "integer"}), - top_k_sparse=_nullable({"type": "integer"}), - top_m_final=_nullable({"type": "integer"}), -) -KNOWLEDGE_BASE_UPDATE_SCHEMA = _object_schema( - kb_name=_nullable({"type": "string"}), - description=_nullable({"type": "string"}), - emoji=_nullable({"type": "string"}), - embedding_provider_id=_nullable({"type": "string"}), - rerank_provider_id=_nullable({"type": "string"}), - chunk_size=_nullable({"type": "integer"}), - chunk_overlap=_nullable({"type": "integer"}), - top_k_dense=_nullable({"type": "integer"}), - top_k_sparse=_nullable({"type": "integer"}), - top_m_final=_nullable({"type": "integer"}), -) -KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA = _object_schema( - required=( - "doc_id", - "kb_id", - "doc_name", - "file_type", - "file_size", - "chunk_count", - "media_count", - ), - doc_id={"type": "string"}, - kb_id={"type": "string"}, - doc_name={"type": "string"}, - file_type={"type": "string"}, - file_size={"type": "integer"}, - file_path={"type": "string"}, - chunk_count={"type": "integer"}, - media_count={"type": "integer"}, - created_at=_nullable({"type": "string"}), - updated_at=_nullable({"type": "string"}), -) -KNOWLEDGE_BASE_RETRIEVE_RESULT_SCHEMA = _object_schema( - required=( - "chunk_id", - "doc_id", - "kb_id", - "kb_name", - "doc_name", - "chunk_index", - "content", - "score", - "char_count", - ), - chunk_id={"type": "string"}, - doc_id={"type": "string"}, - kb_id={"type": "string"}, - kb_name={"type": "string"}, - doc_name={"type": "string"}, - chunk_index={"type": "integer"}, - content={"type": "string"}, - score={"type": "number"}, - char_count={"type": "integer"}, -) -KNOWLEDGE_BASE_DOCUMENT_UPLOAD_SCHEMA = _object_schema( - file_token=_nullable({"type": "string"}), - url=_nullable({"type": "string"}), - text=_nullable({"type": "string"}), - file_name=_nullable({"type": "string"}), - file_type=_nullable({"type": "string"}), - chunk_size=_nullable({"type": "integer"}), - chunk_overlap=_nullable({"type": "integer"}), - batch_size=_nullable({"type": "integer"}), - tasks_limit=_nullable({"type": "integer"}), - max_retries=_nullable({"type": "integer"}), - enable_cleaning=_nullable({"type": "boolean"}), - cleaning_provider_id=_nullable({"type": "string"}), -) -KB_LIST_INPUT_SCHEMA = _object_schema() -KB_LIST_OUTPUT_SCHEMA = _object_schema( - required=("kbs",), - kbs={"type": "array", "items": KNOWLEDGE_BASE_RECORD_SCHEMA}, -) -KB_GET_INPUT_SCHEMA = _object_schema( - required=("kb_id",), - kb_id={"type": "string"}, -) -KB_GET_OUTPUT_SCHEMA = _object_schema( - required=("kb",), - kb=_nullable(KNOWLEDGE_BASE_RECORD_SCHEMA), -) -KB_CREATE_INPUT_SCHEMA = _object_schema( - required=("kb",), - kb=KNOWLEDGE_BASE_CREATE_SCHEMA, -) -KB_CREATE_OUTPUT_SCHEMA = _object_schema( - required=("kb",), - kb=KNOWLEDGE_BASE_RECORD_SCHEMA, -) -KB_UPDATE_INPUT_SCHEMA = _object_schema( - required=("kb_id", "kb"), - kb_id={"type": "string"}, - kb=KNOWLEDGE_BASE_UPDATE_SCHEMA, -) -KB_UPDATE_OUTPUT_SCHEMA = _object_schema( - required=("kb",), - kb=_nullable(KNOWLEDGE_BASE_RECORD_SCHEMA), -) -KB_DELETE_INPUT_SCHEMA = _object_schema( - required=("kb_id",), - kb_id={"type": "string"}, -) -KB_DELETE_OUTPUT_SCHEMA = _object_schema( - required=("deleted",), - deleted={"type": "boolean"}, -) -KB_RETRIEVE_INPUT_SCHEMA = _object_schema( - required=("query",), - query={"type": "string"}, - kb_ids={"type": "array", "items": {"type": "string"}}, - kb_names={"type": "array", "items": {"type": "string"}}, - top_k_fusion={"type": "integer"}, - top_m_final={"type": "integer"}, -) -KB_RETRIEVE_OUTPUT_SCHEMA = _object_schema( - required=("result",), - result=_nullable( - _object_schema( - required=("context_text", "results"), - context_text={"type": "string"}, - results={ - "type": "array", - "items": KNOWLEDGE_BASE_RETRIEVE_RESULT_SCHEMA, - }, - ) - ), -) -KB_DOCUMENT_UPLOAD_INPUT_SCHEMA = _object_schema( - required=("kb_id", "document"), - kb_id={"type": "string"}, - document=KNOWLEDGE_BASE_DOCUMENT_UPLOAD_SCHEMA, -) -KB_DOCUMENT_UPLOAD_OUTPUT_SCHEMA = _object_schema( - required=("document",), - document=KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA, -) -KB_DOCUMENT_LIST_INPUT_SCHEMA = _object_schema( - required=("kb_id",), - kb_id={"type": "string"}, - offset={"type": "integer"}, - limit={"type": "integer"}, -) -KB_DOCUMENT_LIST_OUTPUT_SCHEMA = _object_schema( - required=("documents",), - documents={"type": "array", "items": KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA}, -) -KB_DOCUMENT_GET_INPUT_SCHEMA = _object_schema( - required=("kb_id", "doc_id"), - kb_id={"type": "string"}, - doc_id={"type": "string"}, -) -KB_DOCUMENT_GET_OUTPUT_SCHEMA = _object_schema( - required=("document",), - document=_nullable(KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA), -) -KB_DOCUMENT_DELETE_INPUT_SCHEMA = _object_schema( - required=("kb_id", "doc_id"), - kb_id={"type": "string"}, - doc_id={"type": "string"}, -) -KB_DOCUMENT_DELETE_OUTPUT_SCHEMA = _object_schema( - required=("deleted",), - deleted={"type": "boolean"}, -) -KB_DOCUMENT_REFRESH_INPUT_SCHEMA = _object_schema( - required=("kb_id", "doc_id"), - kb_id={"type": "string"}, - doc_id={"type": "string"}, -) -KB_DOCUMENT_REFRESH_OUTPUT_SCHEMA = _object_schema( - required=("document",), - document=_nullable(KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA), -) -REGISTRY_COMMAND_REGISTER_INPUT_SCHEMA = _object_schema( - required=("command_name", "handler_full_name"), - command_name={"type": "string"}, - handler_full_name={"type": "string"}, - source_event_type={"type": "string"}, - desc={"type": "string"}, - priority={"type": "integer"}, - use_regex={"type": "boolean"}, - ignore_prefix={"type": "boolean"}, -) -REGISTRY_COMMAND_REGISTER_OUTPUT_SCHEMA = _object_schema() -SKILL_REGISTER_INPUT_SCHEMA = _object_schema( - required=("name", "path"), - name={"type": "string"}, - path={"type": "string"}, - description={"type": "string"}, -) -SKILL_REGISTER_OUTPUT_SCHEMA = _object_schema( - required=("name", "description", "path", "skill_dir"), - name={"type": "string"}, - description={"type": "string"}, - path={"type": "string"}, - skill_dir={"type": "string"}, -) -SKILL_UNREGISTER_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -SKILL_UNREGISTER_OUTPUT_SCHEMA = _object_schema( - required=("removed",), - removed={"type": "boolean"}, -) -SKILL_LIST_INPUT_SCHEMA = _object_schema() -SKILL_LIST_OUTPUT_SCHEMA = _object_schema( - required=("skills",), - skills={ - "type": "array", - "items": SKILL_REGISTER_OUTPUT_SCHEMA, - }, -) -HTTP_REGISTER_API_INPUT_SCHEMA = _object_schema( - required=("route", "methods", "handler_capability"), - route={"type": "string"}, - methods={"type": "array", "items": {"type": "string"}}, - handler_capability={"type": "string"}, - description={"type": "string"}, -) -HTTP_REGISTER_API_OUTPUT_SCHEMA = _object_schema() -HTTP_UNREGISTER_API_INPUT_SCHEMA = _object_schema( - required=("route", "methods"), - route={"type": "string"}, - methods={"type": "array", "items": {"type": "string"}}, -) -HTTP_UNREGISTER_API_OUTPUT_SCHEMA = _object_schema() -HTTP_LIST_APIS_INPUT_SCHEMA = _object_schema() -HTTP_LIST_APIS_OUTPUT_SCHEMA = _object_schema( - required=("apis",), - apis={"type": "array", "items": {"type": "object"}}, -) -METADATA_GET_PLUGIN_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -METADATA_GET_PLUGIN_OUTPUT_SCHEMA = _object_schema( - required=("plugin",), - plugin=_nullable({"type": "object"}), -) -METADATA_LIST_PLUGINS_INPUT_SCHEMA = _object_schema() -METADATA_LIST_PLUGINS_OUTPUT_SCHEMA = _object_schema( - required=("plugins",), - plugins={"type": "array", "items": {"type": "object"}}, -) -METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA = _object_schema( - required=("config",), - config=_nullable({"type": "object"}), -) -METADATA_SAVE_PLUGIN_CONFIG_INPUT_SCHEMA = _object_schema( - required=("config",), - config={"type": "object"}, -) -METADATA_SAVE_PLUGIN_CONFIG_OUTPUT_SCHEMA = _object_schema( - required=("config",), - config=_nullable({"type": "object"}), -) -REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_INPUT_SCHEMA = _object_schema( - required=("event_type",), - event_type={"type": "string"}, -) -REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_OUTPUT_SCHEMA = _object_schema( - required=("handlers",), - handlers={"type": "array", "items": {"type": "object"}}, -) -REGISTRY_GET_HANDLER_BY_FULL_NAME_INPUT_SCHEMA = _object_schema( - required=("full_name",), - full_name={"type": "string"}, -) -REGISTRY_GET_HANDLER_BY_FULL_NAME_OUTPUT_SCHEMA = _object_schema( - required=("handler",), - handler=_nullable({"type": "object"}), -) -PROVIDER_META_SCHEMA = _object_schema( - required=("id", "type", "provider_type"), - id={"type": "string"}, - model=_nullable({"type": "string"}), - type={"type": "string"}, - provider_type={"type": "string"}, -) -MANAGED_PROVIDER_RECORD_SCHEMA = _object_schema( - required=("id", "type", "provider_type", "loaded", "enabled"), - id={"type": "string"}, - model=_nullable({"type": "string"}), - type={"type": "string"}, - provider_type={"type": "string"}, - loaded={"type": "boolean"}, - enabled={"type": "boolean"}, - provider_source_id=_nullable({"type": "string"}), -) -PROVIDER_CHANGE_EVENT_SCHEMA = _object_schema( - required=("provider_id", "provider_type"), - provider_id={"type": "string"}, - provider_type={"type": "string"}, - umo=_nullable({"type": "string"}), -) -LLM_TOOL_SPEC_SCHEMA = _object_schema( - required=("name", "description", "parameters_schema", "active"), - name={"type": "string"}, - description={"type": "string"}, - parameters_schema={"type": "object"}, - handler_ref=_nullable({"type": "string"}), - handler_capability=_nullable({"type": "string"}), - active={"type": "boolean"}, -) -AGENT_SPEC_SCHEMA = _object_schema( - required=("name", "description", "tool_names", "runner_class"), - name={"type": "string"}, - description={"type": "string"}, - tool_names={"type": "array", "items": {"type": "string"}}, - runner_class={"type": "string"}, -) -PROVIDER_GET_USING_INPUT_SCHEMA = _object_schema(umo=_nullable({"type": "string"})) -PROVIDER_GET_USING_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(PROVIDER_META_SCHEMA), -) -PROVIDER_GET_BY_ID_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_GET_BY_ID_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(PROVIDER_META_SCHEMA), -) -PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_INPUT_SCHEMA = _object_schema( - umo=_nullable({"type": "string"}), -) -PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_OUTPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id=_nullable({"type": "string"}), -) -PROVIDER_LIST_ALL_INPUT_SCHEMA = _object_schema() -PROVIDER_LIST_ALL_OUTPUT_SCHEMA = _object_schema( - required=("providers",), - providers={"type": "array", "items": PROVIDER_META_SCHEMA}, -) -PROVIDER_STT_GET_TEXT_INPUT_SCHEMA = _object_schema( - required=("provider_id", "audio_url"), - provider_id={"type": "string"}, - audio_url={"type": "string"}, -) -PROVIDER_STT_GET_TEXT_OUTPUT_SCHEMA = _object_schema( - required=("text",), - text={"type": "string"}, -) -PROVIDER_TTS_GET_AUDIO_INPUT_SCHEMA = _object_schema( - required=("provider_id", "text"), - provider_id={"type": "string"}, - text={"type": "string"}, -) -PROVIDER_TTS_GET_AUDIO_OUTPUT_SCHEMA = _object_schema( - required=("audio_path",), - audio_path={"type": "string"}, -) -PROVIDER_TTS_SUPPORT_STREAM_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_TTS_SUPPORT_STREAM_OUTPUT_SCHEMA = _object_schema( - required=("supported",), - supported={"type": "boolean"}, -) -PROVIDER_TTS_AUDIO_CHUNK_SCHEMA = _object_schema( - required=("audio_base64",), - audio_base64={"type": "string"}, - text=_nullable({"type": "string"}), -) -PROVIDER_TTS_GET_AUDIO_STREAM_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, - text=_nullable({"type": "string"}), - text_chunks={"type": "array", "items": {"type": "string"}}, -) -PROVIDER_TTS_GET_AUDIO_STREAM_OUTPUT_SCHEMA = PROVIDER_TTS_AUDIO_CHUNK_SCHEMA -PROVIDER_EMBEDDING_GET_INPUT_SCHEMA = _object_schema( - required=("provider_id", "text"), - provider_id={"type": "string"}, - text={"type": "string"}, -) -PROVIDER_EMBEDDING_GET_OUTPUT_SCHEMA = _object_schema( - required=("embedding",), - embedding={"type": "array", "items": {"type": "number"}}, -) -PROVIDER_EMBEDDING_GET_MANY_INPUT_SCHEMA = _object_schema( - required=("provider_id", "texts"), - provider_id={"type": "string"}, - texts={"type": "array", "items": {"type": "string"}}, -) -PROVIDER_EMBEDDING_GET_MANY_OUTPUT_SCHEMA = _object_schema( - required=("embeddings",), - embeddings={ - "type": "array", - "items": {"type": "array", "items": {"type": "number"}}, - }, -) -PROVIDER_EMBEDDING_GET_DIM_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_EMBEDDING_GET_DIM_OUTPUT_SCHEMA = _object_schema( - required=("dim",), - dim={"type": "integer"}, -) -PROVIDER_RERANK_RESULT_SCHEMA = _object_schema( - required=("index", "score", "document"), - index={"type": "integer"}, - score={"type": "number"}, - document={"type": "string"}, -) -PROVIDER_RERANK_INPUT_SCHEMA = _object_schema( - required=("provider_id", "query", "documents"), - provider_id={"type": "string"}, - query={"type": "string"}, - documents={"type": "array", "items": {"type": "string"}}, - top_n=_nullable({"type": "integer"}), -) -PROVIDER_RERANK_OUTPUT_SCHEMA = _object_schema( - required=("results",), - results={"type": "array", "items": PROVIDER_RERANK_RESULT_SCHEMA}, -) -PROVIDER_MANAGER_SET_INPUT_SCHEMA = _object_schema( - required=("provider_id", "provider_type"), - provider_id={"type": "string"}, - provider_type={"type": "string"}, - umo=_nullable({"type": "string"}), -) -PROVIDER_MANAGER_SET_OUTPUT_SCHEMA = _object_schema() -PROVIDER_MANAGER_GET_BY_ID_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_MANAGER_GET_BY_ID_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(MANAGED_PROVIDER_RECORD_SCHEMA), -) -PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_OUTPUT_SCHEMA = _object_schema( - required=("config",), - config=_nullable({"type": "object"}), -) -PROVIDER_MANAGER_LOAD_INPUT_SCHEMA = _object_schema( - required=("provider_config",), - provider_config={"type": "object"}, -) -PROVIDER_MANAGER_LOAD_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(MANAGED_PROVIDER_RECORD_SCHEMA), -) -PROVIDER_MANAGER_TERMINATE_INPUT_SCHEMA = _object_schema( - required=("provider_id",), - provider_id={"type": "string"}, -) -PROVIDER_MANAGER_TERMINATE_OUTPUT_SCHEMA = _object_schema() -PROVIDER_MANAGER_CREATE_INPUT_SCHEMA = _object_schema( - required=("provider_config",), - provider_config={"type": "object"}, -) -PROVIDER_MANAGER_CREATE_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(MANAGED_PROVIDER_RECORD_SCHEMA), -) -PROVIDER_MANAGER_UPDATE_INPUT_SCHEMA = _object_schema( - required=("origin_provider_id", "new_config"), - origin_provider_id={"type": "string"}, - new_config={"type": "object"}, -) -PROVIDER_MANAGER_UPDATE_OUTPUT_SCHEMA = _object_schema( - required=("provider",), - provider=_nullable(MANAGED_PROVIDER_RECORD_SCHEMA), -) -PROVIDER_MANAGER_DELETE_INPUT_SCHEMA = _object_schema( - provider_id=_nullable({"type": "string"}), - provider_source_id=_nullable({"type": "string"}), -) -PROVIDER_MANAGER_DELETE_OUTPUT_SCHEMA = _object_schema() -PROVIDER_MANAGER_GET_INSTS_INPUT_SCHEMA = _object_schema() -PROVIDER_MANAGER_GET_INSTS_OUTPUT_SCHEMA = _object_schema( - required=("providers",), - providers={"type": "array", "items": MANAGED_PROVIDER_RECORD_SCHEMA}, -) -PROVIDER_MANAGER_WATCH_CHANGES_INPUT_SCHEMA = _object_schema() -PROVIDER_MANAGER_WATCH_CHANGES_OUTPUT_SCHEMA = _object_schema( - required=("provider_id", "provider_type"), - provider_id={"type": "string"}, - provider_type={"type": "string"}, - umo=_nullable({"type": "string"}), -) -LLM_TOOL_MANAGER_GET_INPUT_SCHEMA = _object_schema() -LLM_TOOL_MANAGER_GET_OUTPUT_SCHEMA = _object_schema( - required=("registered", "active"), - registered={"type": "array", "items": LLM_TOOL_SPEC_SCHEMA}, - active={"type": "array", "items": LLM_TOOL_SPEC_SCHEMA}, -) -LLM_TOOL_MANAGER_ACTIVATE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -LLM_TOOL_MANAGER_ACTIVATE_OUTPUT_SCHEMA = _object_schema( - required=("activated",), - activated={"type": "boolean"}, -) -LLM_TOOL_MANAGER_DEACTIVATE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -LLM_TOOL_MANAGER_DEACTIVATE_OUTPUT_SCHEMA = _object_schema( - required=("deactivated",), - deactivated={"type": "boolean"}, -) -LLM_TOOL_MANAGER_ADD_INPUT_SCHEMA = _object_schema( - required=("tools",), - tools={"type": "array", "items": LLM_TOOL_SPEC_SCHEMA}, -) -LLM_TOOL_MANAGER_ADD_OUTPUT_SCHEMA = _object_schema( - required=("names",), - names={"type": "array", "items": {"type": "string"}}, -) -LLM_TOOL_MANAGER_REMOVE_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -LLM_TOOL_MANAGER_REMOVE_OUTPUT_SCHEMA = _object_schema( - required=("removed",), - removed={"type": "boolean"}, -) -AGENT_TOOL_LOOP_RUN_INPUT_SCHEMA = _object_schema( - prompt=_nullable({"type": "string"}), - system_prompt=_nullable({"type": "string"}), - session_id=_nullable({"type": "string"}), - contexts={"type": "array", "items": {"type": "object"}}, - image_urls={"type": "array", "items": {"type": "string"}}, - tool_names=_nullable({"type": "array", "items": {"type": "string"}}), - tool_calls_result={"type": "array", "items": {"type": "object"}}, - provider_id=_nullable({"type": "string"}), - model=_nullable({"type": "string"}), - temperature={"type": "number"}, - max_steps={"type": "integer"}, - tool_call_timeout={"type": "integer"}, -) -AGENT_TOOL_LOOP_RUN_OUTPUT_SCHEMA = LLM_CHAT_RAW_OUTPUT_SCHEMA -AGENT_REGISTRY_LIST_INPUT_SCHEMA = _object_schema() -AGENT_REGISTRY_LIST_OUTPUT_SCHEMA = _object_schema( - required=("agents",), - agents={"type": "array", "items": AGENT_SPEC_SCHEMA}, -) -AGENT_REGISTRY_GET_INPUT_SCHEMA = _object_schema( - required=("name",), - name={"type": "string"}, -) -AGENT_REGISTRY_GET_OUTPUT_SCHEMA = _object_schema( - required=("agent",), - agent=_nullable(AGENT_SPEC_SCHEMA), -) - -BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { - "llm.chat": {"input": LLM_CHAT_INPUT_SCHEMA, "output": LLM_CHAT_OUTPUT_SCHEMA}, - "llm.chat_raw": { - "input": LLM_CHAT_RAW_INPUT_SCHEMA, - "output": LLM_CHAT_RAW_OUTPUT_SCHEMA, - }, - "llm.stream_chat": { - "input": LLM_STREAM_CHAT_INPUT_SCHEMA, - "output": LLM_STREAM_CHAT_OUTPUT_SCHEMA, - }, - "memory.search": { - "input": MEMORY_SEARCH_INPUT_SCHEMA, - "output": MEMORY_SEARCH_OUTPUT_SCHEMA, - }, - "memory.save": { - "input": MEMORY_SAVE_INPUT_SCHEMA, - "output": MEMORY_SAVE_OUTPUT_SCHEMA, - }, - "memory.get": { - "input": MEMORY_GET_INPUT_SCHEMA, - "output": MEMORY_GET_OUTPUT_SCHEMA, - }, - "memory.delete": { - "input": MEMORY_DELETE_INPUT_SCHEMA, - "output": MEMORY_DELETE_OUTPUT_SCHEMA, - }, - "memory.save_with_ttl": { - "input": MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA, - "output": MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA, - }, - "memory.get_many": { - "input": MEMORY_GET_MANY_INPUT_SCHEMA, - "output": MEMORY_GET_MANY_OUTPUT_SCHEMA, - }, - "memory.delete_many": { - "input": MEMORY_DELETE_MANY_INPUT_SCHEMA, - "output": MEMORY_DELETE_MANY_OUTPUT_SCHEMA, - }, - "memory.stats": { - "input": MEMORY_STATS_INPUT_SCHEMA, - "output": MEMORY_STATS_OUTPUT_SCHEMA, - }, - "db.get": {"input": DB_GET_INPUT_SCHEMA, "output": DB_GET_OUTPUT_SCHEMA}, - "db.set": {"input": DB_SET_INPUT_SCHEMA, "output": DB_SET_OUTPUT_SCHEMA}, - "db.delete": {"input": DB_DELETE_INPUT_SCHEMA, "output": DB_DELETE_OUTPUT_SCHEMA}, - "db.list": {"input": DB_LIST_INPUT_SCHEMA, "output": DB_LIST_OUTPUT_SCHEMA}, - "db.get_many": { - "input": DB_GET_MANY_INPUT_SCHEMA, - "output": DB_GET_MANY_OUTPUT_SCHEMA, - }, - "db.set_many": { - "input": DB_SET_MANY_INPUT_SCHEMA, - "output": DB_SET_MANY_OUTPUT_SCHEMA, - }, - "db.watch": {"input": DB_WATCH_INPUT_SCHEMA, "output": DB_WATCH_OUTPUT_SCHEMA}, - "platform.send": { - "input": PLATFORM_SEND_INPUT_SCHEMA, - "output": PLATFORM_SEND_OUTPUT_SCHEMA, - }, - "platform.send_image": { - "input": PLATFORM_SEND_IMAGE_INPUT_SCHEMA, - "output": PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA, - }, - "platform.send_chain": { - "input": PLATFORM_SEND_CHAIN_INPUT_SCHEMA, - "output": PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA, - }, - "platform.send_by_session": { - "input": PLATFORM_SEND_BY_SESSION_INPUT_SCHEMA, - "output": PLATFORM_SEND_BY_SESSION_OUTPUT_SCHEMA, - }, - "platform.get_group": { - "input": PLATFORM_GET_GROUP_INPUT_SCHEMA, - "output": PLATFORM_GET_GROUP_OUTPUT_SCHEMA, - }, - "platform.get_members": { - "input": PLATFORM_GET_MEMBERS_INPUT_SCHEMA, - "output": PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA, - }, - "platform.list_instances": { - "input": PLATFORM_LIST_INSTANCES_INPUT_SCHEMA, - "output": PLATFORM_LIST_INSTANCES_OUTPUT_SCHEMA, - }, - "session.plugin.is_enabled": { - "input": SESSION_PLUGIN_IS_ENABLED_INPUT_SCHEMA, - "output": SESSION_PLUGIN_IS_ENABLED_OUTPUT_SCHEMA, - }, - "session.plugin.filter_handlers": { - "input": SESSION_PLUGIN_FILTER_HANDLERS_INPUT_SCHEMA, - "output": SESSION_PLUGIN_FILTER_HANDLERS_OUTPUT_SCHEMA, - }, - "session.service.is_llm_enabled": { - "input": SESSION_SERVICE_IS_LLM_ENABLED_INPUT_SCHEMA, - "output": SESSION_SERVICE_IS_LLM_ENABLED_OUTPUT_SCHEMA, - }, - "session.service.set_llm_status": { - "input": SESSION_SERVICE_SET_LLM_STATUS_INPUT_SCHEMA, - "output": SESSION_SERVICE_SET_LLM_STATUS_OUTPUT_SCHEMA, - }, - "session.service.is_tts_enabled": { - "input": SESSION_SERVICE_IS_TTS_ENABLED_INPUT_SCHEMA, - "output": SESSION_SERVICE_IS_TTS_ENABLED_OUTPUT_SCHEMA, - }, - "session.service.set_tts_status": { - "input": SESSION_SERVICE_SET_TTS_STATUS_INPUT_SCHEMA, - "output": SESSION_SERVICE_SET_TTS_STATUS_OUTPUT_SCHEMA, - }, - "persona.get": { - "input": PERSONA_GET_INPUT_SCHEMA, - "output": PERSONA_GET_OUTPUT_SCHEMA, - }, - "persona.list": { - "input": PERSONA_LIST_INPUT_SCHEMA, - "output": PERSONA_LIST_OUTPUT_SCHEMA, - }, - "persona.create": { - "input": PERSONA_CREATE_INPUT_SCHEMA, - "output": PERSONA_CREATE_OUTPUT_SCHEMA, - }, - "persona.update": { - "input": PERSONA_UPDATE_INPUT_SCHEMA, - "output": PERSONA_UPDATE_OUTPUT_SCHEMA, - }, - "persona.delete": { - "input": PERSONA_DELETE_INPUT_SCHEMA, - "output": PERSONA_DELETE_OUTPUT_SCHEMA, - }, - "conversation.new": { - "input": CONVERSATION_NEW_INPUT_SCHEMA, - "output": CONVERSATION_NEW_OUTPUT_SCHEMA, - }, - "conversation.switch": { - "input": CONVERSATION_SWITCH_INPUT_SCHEMA, - "output": CONVERSATION_SWITCH_OUTPUT_SCHEMA, - }, - "conversation.delete": { - "input": CONVERSATION_DELETE_INPUT_SCHEMA, - "output": CONVERSATION_DELETE_OUTPUT_SCHEMA, - }, - "conversation.get": { - "input": CONVERSATION_GET_INPUT_SCHEMA, - "output": CONVERSATION_GET_OUTPUT_SCHEMA, - }, - "conversation.get_current": { - "input": CONVERSATION_GET_CURRENT_INPUT_SCHEMA, - "output": CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA, - }, - "conversation.list": { - "input": CONVERSATION_LIST_INPUT_SCHEMA, - "output": CONVERSATION_LIST_OUTPUT_SCHEMA, - }, - "conversation.update": { - "input": CONVERSATION_UPDATE_INPUT_SCHEMA, - "output": CONVERSATION_UPDATE_OUTPUT_SCHEMA, - }, - "conversation.unset_persona": { - "input": CONVERSATION_UNSET_PERSONA_INPUT_SCHEMA, - "output": CONVERSATION_UNSET_PERSONA_OUTPUT_SCHEMA, - }, - "message_history.list": { - "input": MESSAGE_HISTORY_LIST_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_LIST_OUTPUT_SCHEMA, - }, - "message_history.get_by_id": { - "input": MESSAGE_HISTORY_GET_BY_ID_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_GET_BY_ID_OUTPUT_SCHEMA, - }, - "message_history.append": { - "input": MESSAGE_HISTORY_APPEND_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_APPEND_OUTPUT_SCHEMA, - }, - "message_history.delete_before": { - "input": MESSAGE_HISTORY_DELETE_BEFORE_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_DELETE_BEFORE_OUTPUT_SCHEMA, - }, - "message_history.delete_after": { - "input": MESSAGE_HISTORY_DELETE_AFTER_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_DELETE_AFTER_OUTPUT_SCHEMA, - }, - "message_history.delete_all": { - "input": MESSAGE_HISTORY_DELETE_ALL_INPUT_SCHEMA, - "output": MESSAGE_HISTORY_DELETE_ALL_OUTPUT_SCHEMA, - }, - "mcp.local.get": { - "input": MCP_LOCAL_GET_INPUT_SCHEMA, - "output": MCP_LOCAL_GET_OUTPUT_SCHEMA, - }, - "mcp.local.list": { - "input": MCP_LOCAL_LIST_INPUT_SCHEMA, - "output": MCP_LOCAL_LIST_OUTPUT_SCHEMA, - }, - "mcp.local.enable": { - "input": MCP_LOCAL_ENABLE_INPUT_SCHEMA, - "output": MCP_LOCAL_ENABLE_OUTPUT_SCHEMA, - }, - "mcp.local.disable": { - "input": MCP_LOCAL_DISABLE_INPUT_SCHEMA, - "output": MCP_LOCAL_DISABLE_OUTPUT_SCHEMA, - }, - "mcp.local.wait_until_ready": { - "input": MCP_LOCAL_WAIT_UNTIL_READY_INPUT_SCHEMA, - "output": MCP_LOCAL_WAIT_UNTIL_READY_OUTPUT_SCHEMA, - }, - "mcp.session.open": { - "input": MCP_SESSION_OPEN_INPUT_SCHEMA, - "output": MCP_SESSION_OPEN_OUTPUT_SCHEMA, - }, - "mcp.session.list_tools": { - "input": MCP_SESSION_LIST_TOOLS_INPUT_SCHEMA, - "output": MCP_SESSION_LIST_TOOLS_OUTPUT_SCHEMA, - }, - "mcp.session.call_tool": { - "input": MCP_SESSION_CALL_TOOL_INPUT_SCHEMA, - "output": MCP_SESSION_CALL_TOOL_OUTPUT_SCHEMA, - }, - "mcp.session.close": { - "input": MCP_SESSION_CLOSE_INPUT_SCHEMA, - "output": MCP_SESSION_CLOSE_OUTPUT_SCHEMA, - }, - "mcp.global.register": { - "input": MCP_GLOBAL_REGISTER_INPUT_SCHEMA, - "output": MCP_GLOBAL_REGISTER_OUTPUT_SCHEMA, - }, - "mcp.global.get": { - "input": MCP_GLOBAL_GET_INPUT_SCHEMA, - "output": MCP_GLOBAL_GET_OUTPUT_SCHEMA, - }, - "mcp.global.list": { - "input": MCP_GLOBAL_LIST_INPUT_SCHEMA, - "output": MCP_GLOBAL_LIST_OUTPUT_SCHEMA, - }, - "mcp.global.enable": { - "input": MCP_GLOBAL_ENABLE_INPUT_SCHEMA, - "output": MCP_GLOBAL_ENABLE_OUTPUT_SCHEMA, - }, - "mcp.global.disable": { - "input": MCP_GLOBAL_DISABLE_INPUT_SCHEMA, - "output": MCP_GLOBAL_DISABLE_OUTPUT_SCHEMA, - }, - "mcp.global.unregister": { - "input": MCP_GLOBAL_UNREGISTER_INPUT_SCHEMA, - "output": MCP_GLOBAL_UNREGISTER_OUTPUT_SCHEMA, - }, - "internal.mcp.local.execute": { - "input": INTERNAL_MCP_LOCAL_EXECUTE_INPUT_SCHEMA, - "output": INTERNAL_MCP_LOCAL_EXECUTE_OUTPUT_SCHEMA, - }, - "kb.list": {"input": KB_LIST_INPUT_SCHEMA, "output": KB_LIST_OUTPUT_SCHEMA}, - "kb.get": {"input": KB_GET_INPUT_SCHEMA, "output": KB_GET_OUTPUT_SCHEMA}, - "kb.create": { - "input": KB_CREATE_INPUT_SCHEMA, - "output": KB_CREATE_OUTPUT_SCHEMA, - }, - "kb.update": { - "input": KB_UPDATE_INPUT_SCHEMA, - "output": KB_UPDATE_OUTPUT_SCHEMA, - }, - "kb.delete": { - "input": KB_DELETE_INPUT_SCHEMA, - "output": KB_DELETE_OUTPUT_SCHEMA, - }, - "kb.retrieve": { - "input": KB_RETRIEVE_INPUT_SCHEMA, - "output": KB_RETRIEVE_OUTPUT_SCHEMA, - }, - "kb.document.upload": { - "input": KB_DOCUMENT_UPLOAD_INPUT_SCHEMA, - "output": KB_DOCUMENT_UPLOAD_OUTPUT_SCHEMA, - }, - "kb.document.list": { - "input": KB_DOCUMENT_LIST_INPUT_SCHEMA, - "output": KB_DOCUMENT_LIST_OUTPUT_SCHEMA, - }, - "kb.document.get": { - "input": KB_DOCUMENT_GET_INPUT_SCHEMA, - "output": KB_DOCUMENT_GET_OUTPUT_SCHEMA, - }, - "kb.document.delete": { - "input": KB_DOCUMENT_DELETE_INPUT_SCHEMA, - "output": KB_DOCUMENT_DELETE_OUTPUT_SCHEMA, - }, - "kb.document.refresh": { - "input": KB_DOCUMENT_REFRESH_INPUT_SCHEMA, - "output": KB_DOCUMENT_REFRESH_OUTPUT_SCHEMA, - }, - "registry.command.register": { - "input": REGISTRY_COMMAND_REGISTER_INPUT_SCHEMA, - "output": REGISTRY_COMMAND_REGISTER_OUTPUT_SCHEMA, - }, - "skill.register": { - "input": SKILL_REGISTER_INPUT_SCHEMA, - "output": SKILL_REGISTER_OUTPUT_SCHEMA, - }, - "skill.unregister": { - "input": SKILL_UNREGISTER_INPUT_SCHEMA, - "output": SKILL_UNREGISTER_OUTPUT_SCHEMA, - }, - "skill.list": { - "input": SKILL_LIST_INPUT_SCHEMA, - "output": SKILL_LIST_OUTPUT_SCHEMA, - }, - "http.register_api": { - "input": HTTP_REGISTER_API_INPUT_SCHEMA, - "output": HTTP_REGISTER_API_OUTPUT_SCHEMA, - }, - "http.unregister_api": { - "input": HTTP_UNREGISTER_API_INPUT_SCHEMA, - "output": HTTP_UNREGISTER_API_OUTPUT_SCHEMA, - }, - "http.list_apis": { - "input": HTTP_LIST_APIS_INPUT_SCHEMA, - "output": HTTP_LIST_APIS_OUTPUT_SCHEMA, - }, - "metadata.get_plugin": { - "input": METADATA_GET_PLUGIN_INPUT_SCHEMA, - "output": METADATA_GET_PLUGIN_OUTPUT_SCHEMA, - }, - "metadata.list_plugins": { - "input": METADATA_LIST_PLUGINS_INPUT_SCHEMA, - "output": METADATA_LIST_PLUGINS_OUTPUT_SCHEMA, - }, - "metadata.get_plugin_config": { - "input": METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA, - "output": METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA, - }, - "metadata.save_plugin_config": { - "input": METADATA_SAVE_PLUGIN_CONFIG_INPUT_SCHEMA, - "output": METADATA_SAVE_PLUGIN_CONFIG_OUTPUT_SCHEMA, - }, - "registry.get_handlers_by_event_type": { - "input": REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_INPUT_SCHEMA, - "output": REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_OUTPUT_SCHEMA, - }, - "registry.get_handler_by_full_name": { - "input": REGISTRY_GET_HANDLER_BY_FULL_NAME_INPUT_SCHEMA, - "output": REGISTRY_GET_HANDLER_BY_FULL_NAME_OUTPUT_SCHEMA, - }, - "provider.get_using": { - "input": PROVIDER_GET_USING_INPUT_SCHEMA, - "output": PROVIDER_GET_USING_OUTPUT_SCHEMA, - }, - "provider.get_by_id": { - "input": PROVIDER_GET_BY_ID_INPUT_SCHEMA, - "output": PROVIDER_GET_BY_ID_OUTPUT_SCHEMA, - }, - "provider.get_current_chat_provider_id": { - "input": PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_INPUT_SCHEMA, - "output": PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_OUTPUT_SCHEMA, - }, - "provider.list_all": { - "input": PROVIDER_LIST_ALL_INPUT_SCHEMA, - "output": PROVIDER_LIST_ALL_OUTPUT_SCHEMA, - }, - "provider.list_all_tts": { - "input": PROVIDER_LIST_ALL_INPUT_SCHEMA, - "output": PROVIDER_LIST_ALL_OUTPUT_SCHEMA, - }, - "provider.list_all_stt": { - "input": PROVIDER_LIST_ALL_INPUT_SCHEMA, - "output": PROVIDER_LIST_ALL_OUTPUT_SCHEMA, - }, - "provider.list_all_embedding": { - "input": PROVIDER_LIST_ALL_INPUT_SCHEMA, - "output": PROVIDER_LIST_ALL_OUTPUT_SCHEMA, - }, - "provider.list_all_rerank": { - "input": PROVIDER_LIST_ALL_INPUT_SCHEMA, - "output": PROVIDER_LIST_ALL_OUTPUT_SCHEMA, - }, - "provider.get_using_tts": { - "input": PROVIDER_GET_USING_INPUT_SCHEMA, - "output": PROVIDER_GET_USING_OUTPUT_SCHEMA, - }, - "provider.get_using_stt": { - "input": PROVIDER_GET_USING_INPUT_SCHEMA, - "output": PROVIDER_GET_USING_OUTPUT_SCHEMA, - }, - "provider.stt.get_text": { - "input": PROVIDER_STT_GET_TEXT_INPUT_SCHEMA, - "output": PROVIDER_STT_GET_TEXT_OUTPUT_SCHEMA, - }, - "provider.tts.get_audio": { - "input": PROVIDER_TTS_GET_AUDIO_INPUT_SCHEMA, - "output": PROVIDER_TTS_GET_AUDIO_OUTPUT_SCHEMA, - }, - "provider.tts.support_stream": { - "input": PROVIDER_TTS_SUPPORT_STREAM_INPUT_SCHEMA, - "output": PROVIDER_TTS_SUPPORT_STREAM_OUTPUT_SCHEMA, - }, - "provider.tts.get_audio_stream": { - "input": PROVIDER_TTS_GET_AUDIO_STREAM_INPUT_SCHEMA, - "output": PROVIDER_TTS_GET_AUDIO_STREAM_OUTPUT_SCHEMA, - }, - "provider.embedding.get_embedding": { - "input": PROVIDER_EMBEDDING_GET_INPUT_SCHEMA, - "output": PROVIDER_EMBEDDING_GET_OUTPUT_SCHEMA, - }, - "provider.embedding.get_embeddings": { - "input": PROVIDER_EMBEDDING_GET_MANY_INPUT_SCHEMA, - "output": PROVIDER_EMBEDDING_GET_MANY_OUTPUT_SCHEMA, - }, - "provider.embedding.get_dim": { - "input": PROVIDER_EMBEDDING_GET_DIM_INPUT_SCHEMA, - "output": PROVIDER_EMBEDDING_GET_DIM_OUTPUT_SCHEMA, - }, - "provider.rerank.rerank": { - "input": PROVIDER_RERANK_INPUT_SCHEMA, - "output": PROVIDER_RERANK_OUTPUT_SCHEMA, - }, - "provider.manager.set": { - "input": PROVIDER_MANAGER_SET_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_SET_OUTPUT_SCHEMA, - }, - "provider.manager.get_by_id": { - "input": PROVIDER_MANAGER_GET_BY_ID_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_GET_BY_ID_OUTPUT_SCHEMA, - }, - "provider.manager.get_merged_provider_config": { - "input": PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_OUTPUT_SCHEMA, - }, - "provider.manager.load": { - "input": PROVIDER_MANAGER_LOAD_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_LOAD_OUTPUT_SCHEMA, - }, - "provider.manager.terminate": { - "input": PROVIDER_MANAGER_TERMINATE_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_TERMINATE_OUTPUT_SCHEMA, - }, - "provider.manager.create": { - "input": PROVIDER_MANAGER_CREATE_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_CREATE_OUTPUT_SCHEMA, - }, - "provider.manager.update": { - "input": PROVIDER_MANAGER_UPDATE_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_UPDATE_OUTPUT_SCHEMA, - }, - "provider.manager.delete": { - "input": PROVIDER_MANAGER_DELETE_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_DELETE_OUTPUT_SCHEMA, - }, - "provider.manager.get_insts": { - "input": PROVIDER_MANAGER_GET_INSTS_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_GET_INSTS_OUTPUT_SCHEMA, - }, - "provider.manager.watch_changes": { - "input": PROVIDER_MANAGER_WATCH_CHANGES_INPUT_SCHEMA, - "output": PROVIDER_MANAGER_WATCH_CHANGES_OUTPUT_SCHEMA, - }, - "platform.manager.get_by_id": { - "input": PLATFORM_MANAGER_GET_BY_ID_INPUT_SCHEMA, - "output": PLATFORM_MANAGER_GET_BY_ID_OUTPUT_SCHEMA, - }, - "platform.manager.clear_errors": { - "input": PLATFORM_MANAGER_CLEAR_ERRORS_INPUT_SCHEMA, - "output": PLATFORM_MANAGER_CLEAR_ERRORS_OUTPUT_SCHEMA, - }, - "platform.manager.get_stats": { - "input": PLATFORM_MANAGER_GET_STATS_INPUT_SCHEMA, - "output": PLATFORM_MANAGER_GET_STATS_OUTPUT_SCHEMA, - }, - "llm_tool.manager.get": { - "input": LLM_TOOL_MANAGER_GET_INPUT_SCHEMA, - "output": LLM_TOOL_MANAGER_GET_OUTPUT_SCHEMA, - }, - "llm_tool.manager.activate": { - "input": LLM_TOOL_MANAGER_ACTIVATE_INPUT_SCHEMA, - "output": LLM_TOOL_MANAGER_ACTIVATE_OUTPUT_SCHEMA, - }, - "llm_tool.manager.deactivate": { - "input": LLM_TOOL_MANAGER_DEACTIVATE_INPUT_SCHEMA, - "output": LLM_TOOL_MANAGER_DEACTIVATE_OUTPUT_SCHEMA, - }, - "llm_tool.manager.add": { - "input": LLM_TOOL_MANAGER_ADD_INPUT_SCHEMA, - "output": LLM_TOOL_MANAGER_ADD_OUTPUT_SCHEMA, - }, - "llm_tool.manager.remove": { - "input": LLM_TOOL_MANAGER_REMOVE_INPUT_SCHEMA, - "output": LLM_TOOL_MANAGER_REMOVE_OUTPUT_SCHEMA, - }, - "agent.tool_loop.run": { - "input": AGENT_TOOL_LOOP_RUN_INPUT_SCHEMA, - "output": AGENT_TOOL_LOOP_RUN_OUTPUT_SCHEMA, - }, - "agent.registry.list": { - "input": AGENT_REGISTRY_LIST_INPUT_SCHEMA, - "output": AGENT_REGISTRY_LIST_OUTPUT_SCHEMA, - }, - "agent.registry.get": { - "input": AGENT_REGISTRY_GET_INPUT_SCHEMA, - "output": AGENT_REGISTRY_GET_OUTPUT_SCHEMA, - }, - "system.get_data_dir": { - "input": SYSTEM_GET_DATA_DIR_INPUT_SCHEMA, - "output": SYSTEM_GET_DATA_DIR_OUTPUT_SCHEMA, - }, - "system.text_to_image": { - "input": SYSTEM_TEXT_TO_IMAGE_INPUT_SCHEMA, - "output": SYSTEM_TEXT_TO_IMAGE_OUTPUT_SCHEMA, - }, - "system.html_render": { - "input": SYSTEM_HTML_RENDER_INPUT_SCHEMA, - "output": SYSTEM_HTML_RENDER_OUTPUT_SCHEMA, - }, - "system.file.register": { - "input": SYSTEM_FILE_REGISTER_INPUT_SCHEMA, - "output": SYSTEM_FILE_REGISTER_OUTPUT_SCHEMA, - }, - "system.file.handle": { - "input": SYSTEM_FILE_HANDLE_INPUT_SCHEMA, - "output": SYSTEM_FILE_HANDLE_OUTPUT_SCHEMA, - }, - "system.session_waiter.register": { - "input": SYSTEM_SESSION_WAITER_REGISTER_INPUT_SCHEMA, - "output": SYSTEM_SESSION_WAITER_REGISTER_OUTPUT_SCHEMA, - }, - "system.session_waiter.unregister": { - "input": SYSTEM_SESSION_WAITER_UNREGISTER_INPUT_SCHEMA, - "output": SYSTEM_SESSION_WAITER_UNREGISTER_OUTPUT_SCHEMA, - }, - "system.event.react": { - "input": SYSTEM_EVENT_REACT_INPUT_SCHEMA, - "output": SYSTEM_EVENT_REACT_OUTPUT_SCHEMA, - }, - "system.event.send_typing": { - "input": SYSTEM_EVENT_SEND_TYPING_INPUT_SCHEMA, - "output": SYSTEM_EVENT_SEND_TYPING_OUTPUT_SCHEMA, - }, - "system.event.send_streaming": { - "input": SYSTEM_EVENT_SEND_STREAMING_INPUT_SCHEMA, - "output": SYSTEM_EVENT_SEND_STREAMING_OUTPUT_SCHEMA, - }, - "system.event.send_streaming_chunk": { - "input": SYSTEM_EVENT_SEND_STREAMING_CHUNK_INPUT_SCHEMA, - "output": SYSTEM_EVENT_SEND_STREAMING_CHUNK_OUTPUT_SCHEMA, - }, - "system.event.send_streaming_close": { - "input": SYSTEM_EVENT_SEND_STREAMING_CLOSE_INPUT_SCHEMA, - "output": SYSTEM_EVENT_SEND_STREAMING_CLOSE_OUTPUT_SCHEMA, - }, - "system.event.llm.get_state": { - "input": SYSTEM_EVENT_LLM_GET_STATE_INPUT_SCHEMA, - "output": SYSTEM_EVENT_LLM_GET_STATE_OUTPUT_SCHEMA, - }, - "system.event.llm.request": { - "input": SYSTEM_EVENT_LLM_REQUEST_INPUT_SCHEMA, - "output": SYSTEM_EVENT_LLM_REQUEST_OUTPUT_SCHEMA, - }, - "system.event.result.get": { - "input": SYSTEM_EVENT_RESULT_GET_INPUT_SCHEMA, - "output": SYSTEM_EVENT_RESULT_GET_OUTPUT_SCHEMA, - }, - "system.event.result.set": { - "input": SYSTEM_EVENT_RESULT_SET_INPUT_SCHEMA, - "output": SYSTEM_EVENT_RESULT_SET_OUTPUT_SCHEMA, - }, - "system.event.result.clear": { - "input": SYSTEM_EVENT_RESULT_CLEAR_INPUT_SCHEMA, - "output": SYSTEM_EVENT_RESULT_CLEAR_OUTPUT_SCHEMA, - }, - "system.event.handler_whitelist.get": { - "input": SYSTEM_EVENT_HANDLER_WHITELIST_GET_INPUT_SCHEMA, - "output": SYSTEM_EVENT_HANDLER_WHITELIST_GET_OUTPUT_SCHEMA, - }, - "system.event.handler_whitelist.set": { - "input": SYSTEM_EVENT_HANDLER_WHITELIST_SET_INPUT_SCHEMA, - "output": SYSTEM_EVENT_HANDLER_WHITELIST_SET_OUTPUT_SCHEMA, - }, -} - - -__all__ = [ - "BUILTIN_CAPABILITY_SCHEMAS", - "DB_DELETE_INPUT_SCHEMA", - "DB_DELETE_OUTPUT_SCHEMA", - "DB_GET_INPUT_SCHEMA", - "DB_GET_MANY_INPUT_SCHEMA", - "DB_GET_MANY_OUTPUT_SCHEMA", - "DB_GET_OUTPUT_SCHEMA", - "DB_LIST_INPUT_SCHEMA", - "DB_LIST_OUTPUT_SCHEMA", - "DB_SET_INPUT_SCHEMA", - "DB_SET_MANY_INPUT_SCHEMA", - "DB_SET_MANY_OUTPUT_SCHEMA", - "DB_SET_OUTPUT_SCHEMA", - "DB_WATCH_INPUT_SCHEMA", - "DB_WATCH_OUTPUT_SCHEMA", - "HTTP_LIST_APIS_INPUT_SCHEMA", - "HTTP_LIST_APIS_OUTPUT_SCHEMA", - "HTTP_REGISTER_API_INPUT_SCHEMA", - "HTTP_REGISTER_API_OUTPUT_SCHEMA", - "HTTP_UNREGISTER_API_INPUT_SCHEMA", - "HTTP_UNREGISTER_API_OUTPUT_SCHEMA", - "JSONSchema", - "LLM_CHAT_INPUT_SCHEMA", - "LLM_CHAT_OUTPUT_SCHEMA", - "LLM_CHAT_RAW_INPUT_SCHEMA", - "LLM_CHAT_RAW_OUTPUT_SCHEMA", - "LLM_STREAM_CHAT_INPUT_SCHEMA", - "LLM_STREAM_CHAT_OUTPUT_SCHEMA", - "MEMORY_DELETE_INPUT_SCHEMA", - "MEMORY_DELETE_MANY_INPUT_SCHEMA", - "MEMORY_DELETE_MANY_OUTPUT_SCHEMA", - "MEMORY_DELETE_OUTPUT_SCHEMA", - "MEMORY_GET_INPUT_SCHEMA", - "MEMORY_GET_MANY_INPUT_SCHEMA", - "MEMORY_GET_MANY_OUTPUT_SCHEMA", - "MEMORY_GET_OUTPUT_SCHEMA", - "MEMORY_SAVE_INPUT_SCHEMA", - "MEMORY_SAVE_OUTPUT_SCHEMA", - "MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA", - "MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA", - "MEMORY_SEARCH_INPUT_SCHEMA", - "MEMORY_SEARCH_OUTPUT_SCHEMA", - "MEMORY_STATS_INPUT_SCHEMA", - "MEMORY_STATS_OUTPUT_SCHEMA", - "METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA", - "METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA", - "METADATA_SAVE_PLUGIN_CONFIG_INPUT_SCHEMA", - "METADATA_SAVE_PLUGIN_CONFIG_OUTPUT_SCHEMA", - "METADATA_GET_PLUGIN_INPUT_SCHEMA", - "METADATA_GET_PLUGIN_OUTPUT_SCHEMA", - "METADATA_LIST_PLUGINS_INPUT_SCHEMA", - "METADATA_LIST_PLUGINS_OUTPUT_SCHEMA", - "PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_INPUT_SCHEMA", - "PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_OUTPUT_SCHEMA", - "PROVIDER_GET_BY_ID_INPUT_SCHEMA", - "PROVIDER_GET_BY_ID_OUTPUT_SCHEMA", - "PROVIDER_GET_USING_INPUT_SCHEMA", - "PROVIDER_GET_USING_OUTPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_DIM_INPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_DIM_OUTPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_INPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_MANY_INPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_MANY_OUTPUT_SCHEMA", - "PROVIDER_EMBEDDING_GET_OUTPUT_SCHEMA", - "PROVIDER_CHANGE_EVENT_SCHEMA", - "PROVIDER_LIST_ALL_INPUT_SCHEMA", - "PROVIDER_LIST_ALL_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_CREATE_INPUT_SCHEMA", - "PROVIDER_MANAGER_CREATE_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_DELETE_INPUT_SCHEMA", - "PROVIDER_MANAGER_DELETE_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_GET_BY_ID_INPUT_SCHEMA", - "PROVIDER_MANAGER_GET_BY_ID_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_INPUT_SCHEMA", - "PROVIDER_MANAGER_GET_MERGED_PROVIDER_CONFIG_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_GET_INSTS_INPUT_SCHEMA", - "PROVIDER_MANAGER_GET_INSTS_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_LOAD_INPUT_SCHEMA", - "PROVIDER_MANAGER_LOAD_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_SET_INPUT_SCHEMA", - "PROVIDER_MANAGER_SET_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_TERMINATE_INPUT_SCHEMA", - "PROVIDER_MANAGER_TERMINATE_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_UPDATE_INPUT_SCHEMA", - "PROVIDER_MANAGER_UPDATE_OUTPUT_SCHEMA", - "PROVIDER_MANAGER_WATCH_CHANGES_INPUT_SCHEMA", - "PROVIDER_MANAGER_WATCH_CHANGES_OUTPUT_SCHEMA", - "PROVIDER_META_SCHEMA", - "PROVIDER_RERANK_INPUT_SCHEMA", - "PROVIDER_RERANK_OUTPUT_SCHEMA", - "PROVIDER_RERANK_RESULT_SCHEMA", - "PROVIDER_STT_GET_TEXT_INPUT_SCHEMA", - "PROVIDER_STT_GET_TEXT_OUTPUT_SCHEMA", - "PROVIDER_TTS_AUDIO_CHUNK_SCHEMA", - "PROVIDER_TTS_GET_AUDIO_INPUT_SCHEMA", - "PROVIDER_TTS_GET_AUDIO_OUTPUT_SCHEMA", - "PROVIDER_TTS_GET_AUDIO_STREAM_INPUT_SCHEMA", - "PROVIDER_TTS_GET_AUDIO_STREAM_OUTPUT_SCHEMA", - "PROVIDER_TTS_SUPPORT_STREAM_INPUT_SCHEMA", - "PROVIDER_TTS_SUPPORT_STREAM_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_ACTIVATE_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_ACTIVATE_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_ADD_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_ADD_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_REMOVE_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_REMOVE_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_DEACTIVATE_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_DEACTIVATE_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_GET_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_GET_OUTPUT_SCHEMA", - "LLM_TOOL_SPEC_SCHEMA", - "AGENT_REGISTRY_GET_INPUT_SCHEMA", - "AGENT_REGISTRY_GET_OUTPUT_SCHEMA", - "AGENT_REGISTRY_LIST_INPUT_SCHEMA", - "AGENT_REGISTRY_LIST_OUTPUT_SCHEMA", - "AGENT_SPEC_SCHEMA", - "AGENT_TOOL_LOOP_RUN_INPUT_SCHEMA", - "AGENT_TOOL_LOOP_RUN_OUTPUT_SCHEMA", - "MANAGED_PROVIDER_RECORD_SCHEMA", - "PLATFORM_ERROR_SCHEMA", - "PLATFORM_GET_MEMBERS_INPUT_SCHEMA", - "PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA", - "PLATFORM_GET_GROUP_INPUT_SCHEMA", - "PLATFORM_GET_GROUP_OUTPUT_SCHEMA", - "PLATFORM_INSTANCE_SCHEMA", - "PLATFORM_LIST_INSTANCES_INPUT_SCHEMA", - "PLATFORM_LIST_INSTANCES_OUTPUT_SCHEMA", - "PLATFORM_MANAGER_CLEAR_ERRORS_INPUT_SCHEMA", - "PLATFORM_MANAGER_CLEAR_ERRORS_OUTPUT_SCHEMA", - "PLATFORM_MANAGER_GET_BY_ID_INPUT_SCHEMA", - "PLATFORM_MANAGER_GET_BY_ID_OUTPUT_SCHEMA", - "PLATFORM_MANAGER_GET_STATS_INPUT_SCHEMA", - "PLATFORM_MANAGER_GET_STATS_OUTPUT_SCHEMA", - "PLATFORM_MANAGER_STATE_SCHEMA", - "PLATFORM_SEND_CHAIN_INPUT_SCHEMA", - "PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA", - "PLATFORM_SEND_BY_SESSION_INPUT_SCHEMA", - "PLATFORM_SEND_BY_SESSION_OUTPUT_SCHEMA", - "PLATFORM_SEND_IMAGE_INPUT_SCHEMA", - "PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA", - "PLATFORM_SEND_INPUT_SCHEMA", - "PLATFORM_SEND_OUTPUT_SCHEMA", - "PLATFORM_STATS_SCHEMA", - "PERSONA_CREATE_INPUT_SCHEMA", - "PERSONA_CREATE_OUTPUT_SCHEMA", - "PERSONA_CREATE_SCHEMA", - "PERSONA_DELETE_INPUT_SCHEMA", - "PERSONA_DELETE_OUTPUT_SCHEMA", - "PERSONA_GET_INPUT_SCHEMA", - "PERSONA_GET_OUTPUT_SCHEMA", - "PERSONA_LIST_INPUT_SCHEMA", - "PERSONA_LIST_OUTPUT_SCHEMA", - "PERSONA_RECORD_SCHEMA", - "PERSONA_UPDATE_INPUT_SCHEMA", - "PERSONA_UPDATE_OUTPUT_SCHEMA", - "PERSONA_UPDATE_SCHEMA", - "CONVERSATION_CREATE_SCHEMA", - "CONVERSATION_DELETE_INPUT_SCHEMA", - "CONVERSATION_DELETE_OUTPUT_SCHEMA", - "CONVERSATION_GET_CURRENT_INPUT_SCHEMA", - "CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA", - "CONVERSATION_GET_INPUT_SCHEMA", - "CONVERSATION_GET_OUTPUT_SCHEMA", - "CONVERSATION_LIST_INPUT_SCHEMA", - "CONVERSATION_LIST_OUTPUT_SCHEMA", - "CONVERSATION_NEW_INPUT_SCHEMA", - "CONVERSATION_NEW_OUTPUT_SCHEMA", - "CONVERSATION_RECORD_SCHEMA", - "CONVERSATION_SWITCH_INPUT_SCHEMA", - "CONVERSATION_SWITCH_OUTPUT_SCHEMA", - "CONVERSATION_UNSET_PERSONA_INPUT_SCHEMA", - "CONVERSATION_UNSET_PERSONA_OUTPUT_SCHEMA", - "CONVERSATION_UPDATE_INPUT_SCHEMA", - "CONVERSATION_UPDATE_OUTPUT_SCHEMA", - "CONVERSATION_UPDATE_SCHEMA", - "MESSAGE_HISTORY_APPEND_INPUT_SCHEMA", - "MESSAGE_HISTORY_APPEND_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_AFTER_INPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_AFTER_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_ALL_INPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_ALL_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_BEFORE_INPUT_SCHEMA", - "MESSAGE_HISTORY_DELETE_BEFORE_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_GET_BY_ID_INPUT_SCHEMA", - "MESSAGE_HISTORY_GET_BY_ID_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_LIST_INPUT_SCHEMA", - "MESSAGE_HISTORY_LIST_OUTPUT_SCHEMA", - "MESSAGE_HISTORY_PAGE_SCHEMA", - "MESSAGE_HISTORY_RECORD_SCHEMA", - "MESSAGE_HISTORY_SENDER_SCHEMA", - "MESSAGE_HISTORY_SESSION_SCHEMA", - "KB_CREATE_INPUT_SCHEMA", - "KB_CREATE_OUTPUT_SCHEMA", - "KB_DOCUMENT_DELETE_INPUT_SCHEMA", - "KB_DOCUMENT_DELETE_OUTPUT_SCHEMA", - "KB_DOCUMENT_GET_INPUT_SCHEMA", - "KB_DOCUMENT_GET_OUTPUT_SCHEMA", - "KB_DOCUMENT_LIST_INPUT_SCHEMA", - "KB_DOCUMENT_LIST_OUTPUT_SCHEMA", - "KB_DOCUMENT_REFRESH_INPUT_SCHEMA", - "KB_DOCUMENT_REFRESH_OUTPUT_SCHEMA", - "KB_DOCUMENT_UPLOAD_INPUT_SCHEMA", - "KB_DOCUMENT_UPLOAD_OUTPUT_SCHEMA", - "KB_DELETE_INPUT_SCHEMA", - "KB_DELETE_OUTPUT_SCHEMA", - "KB_GET_INPUT_SCHEMA", - "KB_GET_OUTPUT_SCHEMA", - "KB_LIST_INPUT_SCHEMA", - "KB_LIST_OUTPUT_SCHEMA", - "KB_RETRIEVE_INPUT_SCHEMA", - "KB_RETRIEVE_OUTPUT_SCHEMA", - "KB_UPDATE_INPUT_SCHEMA", - "KB_UPDATE_OUTPUT_SCHEMA", - "KNOWLEDGE_BASE_CREATE_SCHEMA", - "KNOWLEDGE_BASE_DOCUMENT_RECORD_SCHEMA", - "KNOWLEDGE_BASE_DOCUMENT_UPLOAD_SCHEMA", - "KNOWLEDGE_BASE_RECORD_SCHEMA", - "KNOWLEDGE_BASE_RETRIEVE_RESULT_SCHEMA", - "KNOWLEDGE_BASE_UPDATE_SCHEMA", - "REGISTRY_COMMAND_REGISTER_INPUT_SCHEMA", - "REGISTRY_COMMAND_REGISTER_OUTPUT_SCHEMA", - "SKILL_REGISTER_INPUT_SCHEMA", - "SKILL_REGISTER_OUTPUT_SCHEMA", - "SKILL_UNREGISTER_INPUT_SCHEMA", - "SKILL_UNREGISTER_OUTPUT_SCHEMA", - "SKILL_LIST_INPUT_SCHEMA", - "SKILL_LIST_OUTPUT_SCHEMA", - "REGISTRY_GET_HANDLER_BY_FULL_NAME_INPUT_SCHEMA", - "REGISTRY_GET_HANDLER_BY_FULL_NAME_OUTPUT_SCHEMA", - "REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_INPUT_SCHEMA", - "REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_OUTPUT_SCHEMA", - "SESSION_PLUGIN_FILTER_HANDLERS_INPUT_SCHEMA", - "SESSION_PLUGIN_FILTER_HANDLERS_OUTPUT_SCHEMA", - "SESSION_PLUGIN_IS_ENABLED_INPUT_SCHEMA", - "SESSION_PLUGIN_IS_ENABLED_OUTPUT_SCHEMA", - "SESSION_REF_SCHEMA", - "SESSION_SERVICE_IS_LLM_ENABLED_INPUT_SCHEMA", - "SESSION_SERVICE_IS_LLM_ENABLED_OUTPUT_SCHEMA", - "SESSION_SERVICE_IS_TTS_ENABLED_INPUT_SCHEMA", - "SESSION_SERVICE_IS_TTS_ENABLED_OUTPUT_SCHEMA", - "SESSION_SERVICE_SET_LLM_STATUS_INPUT_SCHEMA", - "SESSION_SERVICE_SET_LLM_STATUS_OUTPUT_SCHEMA", - "SESSION_SERVICE_SET_TTS_STATUS_INPUT_SCHEMA", - "SESSION_SERVICE_SET_TTS_STATUS_OUTPUT_SCHEMA", - "SYSTEM_EVENT_REACT_INPUT_SCHEMA", - "SYSTEM_EVENT_REACT_OUTPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_GET_INPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_GET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_SET_INPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_SET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_LLM_GET_STATE_INPUT_SCHEMA", - "SYSTEM_EVENT_LLM_GET_STATE_OUTPUT_SCHEMA", - "SYSTEM_EVENT_LLM_REQUEST_INPUT_SCHEMA", - "SYSTEM_EVENT_LLM_REQUEST_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_CLEAR_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_CLEAR_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_GET_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_GET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_SET_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_SET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CHUNK_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CHUNK_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CLOSE_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CLOSE_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_TYPING_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_TYPING_OUTPUT_SCHEMA", - "SYSTEM_FILE_HANDLE_INPUT_SCHEMA", - "SYSTEM_FILE_HANDLE_OUTPUT_SCHEMA", - "SYSTEM_FILE_REGISTER_INPUT_SCHEMA", - "SYSTEM_FILE_REGISTER_OUTPUT_SCHEMA", -] diff --git a/astrbot-sdk/src/astrbot_sdk/protocol/descriptors.py b/astrbot-sdk/src/astrbot_sdk/protocol/descriptors.py deleted file mode 100644 index 5ef1c1bf7..000000000 --- a/astrbot-sdk/src/astrbot_sdk/protocol/descriptors.py +++ /dev/null @@ -1,521 +0,0 @@ -"""v4 协议描述符模型。 - -`protocol` 是 v4 新引入的协议层抽象,不对应旧树(圣诞树)中的一个同名目录。这里 -定义的是跨进程握手和调度时使用的声明式元数据,而不是运行时的具体处理器/ -能力实现。 -""" - -from __future__ import annotations - -from typing import Annotated, Any, Literal - -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator - -from . import _builtin_schemas -from ._builtin_schemas import * # noqa: F403 - -JSONSchema = _builtin_schemas.JSONSchema -RESERVED_CAPABILITY_NAMESPACES = ("handler", "system", "internal") -RESERVED_CAPABILITY_PREFIXES = tuple( - f"{namespace}." for namespace in RESERVED_CAPABILITY_NAMESPACES -) -BUILTIN_CAPABILITY_SCHEMAS = _builtin_schemas.BUILTIN_CAPABILITY_SCHEMAS -_BUILTIN_SCHEMA_EXPORTS = frozenset(_builtin_schemas.__all__) - - -def __getattr__(name: str) -> Any: - if name in _BUILTIN_SCHEMA_EXPORTS: - return getattr(_builtin_schemas, name) - raise AttributeError(name) - - -def __dir__() -> list[str]: - return sorted(set(globals()) | _BUILTIN_SCHEMA_EXPORTS) - - -class _DescriptorBase(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class Permissions(_DescriptorBase): - """权限配置,控制处理器的访问权限。 - - Attributes: - require_admin: 是否需要管理员权限 - level: 权限等级,数值越高权限越大 - """ - - require_admin: bool = False - level: int = 0 - - -class SessionRef(_DescriptorBase): - """结构化会话目标。 - - v4 运行时内部仍然保留 legacy `session` 字符串作为最低兼容层, - 但对外模型允许同时携带平台与原始寻址信息,避免平台发送接口长期 - 只依赖一个不透明字符串。 - """ - - conversation_id: str = Field( - validation_alias=AliasChoices("conversation_id", "session"), - ) - platform: str | None = None - raw: dict[str, Any] | None = None - - @property - def session(self) -> str: - return self.conversation_id - - def to_payload(self) -> dict[str, Any]: - return self.model_dump(exclude_none=True) - - -class CommandTrigger(_DescriptorBase): - """命令触发器,响应特定命令。 - - Attributes: - type: 触发器类型,固定为 "command" - command: 命令名称(不含前缀,如 "help") - aliases: 命令别名列表 - description: 命令描述,用于帮助文档 - platforms: 允许的平台列表,为空表示所有平台 - message_types: 限定的消息类型列表,为空表示不限 - """ - - type: Literal["command"] = "command" - command: str - aliases: list[str] = Field(default_factory=list) - description: str | None = None - platforms: list[str] = Field(default_factory=list) - message_types: list[str] = Field(default_factory=list) - - -class MessageTrigger(_DescriptorBase): - """消息触发器,描述消息类处理器的订阅条件。 - - Attributes: - type: 触发器类型,固定为 "message" - regex: 正则表达式模式,匹配消息文本 - keywords: 关键词列表,消息包含任一关键词即触发 - platforms: 目标平台列表,为空表示所有平台 - message_types: 限定的消息类型列表,为空表示不限 - - Note: - `regex` 和 `keywords` 可以同时为空,此时表示 "任意消息均可触发", - 仅由平台过滤或上层运行时进一步筛选。 - """ - - type: Literal["message"] = "message" - regex: str | None = None - keywords: list[str] = Field(default_factory=list) - platforms: list[str] = Field(default_factory=list) - message_types: list[str] = Field(default_factory=list) - - -class EventTrigger(_DescriptorBase): - """事件触发器,响应特定类型的事件。 - - Attributes: - type: 触发器类型,固定为 "event" - event_type: 事件类型,字符串形式(如 "message"、"notice") - """ - - type: Literal["event"] = "event" - event_type: str - - -class ScheduleTrigger(_DescriptorBase): - """定时触发器,按 cron 表达式或固定间隔执行。 - - Attributes: - type: 触发器类型,固定为 "schedule" - cron: cron 表达式(如 "0 9 * * *" 表示每天 9 点) - interval_seconds: 执行间隔(秒) - - Note: - cron 和 interval_seconds 必须且只能有一个非空。 - """ - - type: Literal["schedule"] = "schedule" - cron: str | None = Field( - default=None, - validation_alias=AliasChoices("cron", "schedule"), - ) - interval_seconds: int | None = None - - @property - def schedule(self) -> str | None: - return self.cron - - @model_validator(mode="after") - def validate_schedule(self) -> ScheduleTrigger: - has_cron = self.cron is not None - has_interval = self.interval_seconds is not None - if has_cron == has_interval: - raise ValueError("cron 和 interval_seconds 必须且只能有一个非 null") - return self - - -class PlatformFilterSpec(_DescriptorBase): - kind: Literal["platform"] = "platform" - platforms: list[str] = Field(default_factory=list) - - -class MessageTypeFilterSpec(_DescriptorBase): - kind: Literal["message_type"] = "message_type" - message_types: list[str] = Field(default_factory=list) - - -class LocalFilterRefSpec(_DescriptorBase): - kind: Literal["local"] = "local" - filter_id: str - args: dict[str, Any] = Field(default_factory=dict) - - -class CompositeFilterSpec(_DescriptorBase): - kind: Literal["and", "or"] - children: list[FilterSpec] = Field(default_factory=list) - - -FilterSpec = Annotated[ - PlatformFilterSpec - | MessageTypeFilterSpec - | LocalFilterRefSpec - | CompositeFilterSpec, - Field(discriminator="kind"), -] - - -class ParamSpec(_DescriptorBase): - name: str - type: Literal["str", "int", "float", "bool", "optional", "greedy_str"] - required: bool = True - inner_type: Literal["str", "int", "float", "bool"] | None = None - - -class CommandRouteSpec(_DescriptorBase): - group_path: list[str] = Field(default_factory=list) - display_command: str - group_help: str | None = None - - -CompositeFilterSpec.model_rebuild() - - -Trigger = Annotated[ - CommandTrigger | MessageTrigger | EventTrigger | ScheduleTrigger, - Field(discriminator="type"), -] -"""触发器联合类型,使用 type 字段作为判别器自动解析具体类型。""" - - -class HandlerDescriptor(_DescriptorBase): - """处理器描述符,描述一个事件处理函数的元信息。 - - Attributes: - id: 处理器唯一标识,通常是 "模块.函数名" 格式 - trigger: 触发器配置,决定何时执行该处理器 - kind: 处理器类别,默认普通 handler - contract: 运行时契约名,描述入参/执行语义 - priority: 优先级,数值越大越先执行 - permissions: 权限配置,控制谁可以触发该处理器 - - 使用场景: - HandlerDescriptor 通常由 `@on_command`、`@on_message` 等装饰器自动创建, - 插件作者一般不需要手动实例化。但了解其结构有助于理解插件注册机制。 - - 触发器类型: - - CommandTrigger: 响应特定命令,如 `/help` - - MessageTrigger: 响应消息(正则/关键词匹配) - - EventTrigger: 响应特定事件类型 - - ScheduleTrigger: 定时触发 - - 示例: - 插件作者通常通过装饰器声明处理器,框架会自动生成 HandlerDescriptor: - - ```python - from astrbot_sdk.decorators import on_command, on_message - - # 命令处理器 - @on_command("hello") - async def hello_handler(ctx: Context): - await ctx.reply("Hello!") - - # 消息处理器(正则匹配) - @on_message(regex=r"^test\\s+(.+)$") - async def test_handler(ctx: Context): - await ctx.reply(f"收到: {ctx.match.group(1)}") - ``` - - See Also: - Trigger: 触发器联合类型 - Permissions: 权限配置 - """ - - id: str - trigger: Trigger - kind: Literal["handler", "hook", "tool", "session"] = "handler" - contract: str | None = None - description: str | None = None - priority: int = 0 - permissions: Permissions = Field(default_factory=Permissions) - filters: list[FilterSpec] = Field(default_factory=list) - param_specs: list[ParamSpec] = Field(default_factory=list) - command_route: CommandRouteSpec | None = None - - @model_validator(mode="after") - def validate_contract_defaults(self) -> HandlerDescriptor: - if self.contract is None: - if isinstance(self.trigger, ScheduleTrigger): - self.contract = "schedule" - else: - self.contract = "message_event" - return self - - -class CapabilityDescriptor(_DescriptorBase): - """能力描述符,描述一个可调用的远程能力。 - - 能力命名规范: - - 使用 "namespace.action" 格式,如 "llm.chat"、"db.set" - - 支持多级命名空间,如 "llm_tool.manager.activate" - - 内置能力以 "internal." 开头,如 "internal.legacy.call_context_function" - - 保留命名空间(插件不可使用): - - `handler.` - 处理器相关 - - `system.` - 系统内部能力 - - `internal.` - 内部实现细节 - - Attributes: - name: 能力名称,格式为 "namespace.action" - description: 能力描述,用于文档和调试 - input_schema: 输入参数的 JSON Schema,用于验证 - output_schema: 输出结果的 JSON Schema,用于验证 - supports_stream: 是否支持流式响应 - cancelable: 是否支持取消 - - 使用场景: - 当你的插件需要**暴露**一个可被其他插件调用的能力时,使用此类声明。 - - 示例: - ```python - from astrbot_sdk.protocol import CapabilityDescriptor - - # 声明一个翻译能力 - translate_desc = CapabilityDescriptor( - name="my_plugin.translate", - description="翻译文本到指定语言", - input_schema={ - "type": "object", - "properties": { - "text": {"type": "string", "description": "要翻译的文本"}, - "target_lang": {"type": "string", "description": "目标语言"}, - }, - "required": ["text", "target_lang"], - }, - output_schema={ - "type": "object", - "properties": { - "translated": {"type": "string"}, - }, - }, - ) - - # 声明一个流式数据能力 - stream_desc = CapabilityDescriptor( - name="my_plugin.stream_data", - description="流式返回数据", - supports_stream=True, - cancelable=True, - input_schema={"type": "object", "properties": {"count": {"type": "integer"}}}, - output_schema={"type": "object", "properties": {"items": {"type": "array"}}}, - ) - ``` - - 注意: - 如果你要调用**内置能力**(如 `llm.chat`、`db.set`),不需要手动创建 - CapabilityDescriptor,而是直接通过 `Context.invoke()` 调用,或查阅 - `BUILTIN_CAPABILITY_SCHEMAS` 了解参数格式。 - - See Also: - BUILTIN_CAPABILITY_SCHEMAS: 内置能力的 schema 定义,用于查询参数格式 - """ - - name: str - description: str - input_schema: JSONSchema | None = None - output_schema: JSONSchema | None = None - supports_stream: bool = False - cancelable: bool = False - - @model_validator(mode="after") - def validate_builtin_schema_governance(self) -> CapabilityDescriptor: - builtin_schema = BUILTIN_CAPABILITY_SCHEMAS.get(self.name) - if builtin_schema is None: - return self - if self.input_schema is None or self.output_schema is None: - raise ValueError( - f"内建 capability {self.name} 必须同时提供 input_schema 和 output_schema" - ) - if ( - self.input_schema != builtin_schema["input"] - or self.output_schema != builtin_schema["output"] - ): - raise ValueError( - f"内建 capability {self.name} 的 schema 必须与协议注册表保持一致" - ) - return self - - -__all__ = [ - "AGENT_REGISTRY_GET_INPUT_SCHEMA", - "AGENT_REGISTRY_GET_OUTPUT_SCHEMA", - "AGENT_REGISTRY_LIST_INPUT_SCHEMA", - "AGENT_REGISTRY_LIST_OUTPUT_SCHEMA", - "AGENT_SPEC_SCHEMA", - "AGENT_TOOL_LOOP_RUN_INPUT_SCHEMA", - "AGENT_TOOL_LOOP_RUN_OUTPUT_SCHEMA", - "BUILTIN_CAPABILITY_SCHEMAS", - "CapabilityDescriptor", - "CommandRouteSpec", - "CommandTrigger", - "CompositeFilterSpec", - "DB_DELETE_INPUT_SCHEMA", - "DB_DELETE_OUTPUT_SCHEMA", - "DB_GET_INPUT_SCHEMA", - "DB_GET_MANY_INPUT_SCHEMA", - "DB_GET_MANY_OUTPUT_SCHEMA", - "DB_GET_OUTPUT_SCHEMA", - "DB_LIST_INPUT_SCHEMA", - "DB_LIST_OUTPUT_SCHEMA", - "DB_SET_INPUT_SCHEMA", - "DB_SET_MANY_INPUT_SCHEMA", - "DB_SET_MANY_OUTPUT_SCHEMA", - "DB_SET_OUTPUT_SCHEMA", - "DB_WATCH_INPUT_SCHEMA", - "DB_WATCH_OUTPUT_SCHEMA", - "EventTrigger", - "FilterSpec", - "HTTP_LIST_APIS_INPUT_SCHEMA", - "HTTP_LIST_APIS_OUTPUT_SCHEMA", - "HTTP_REGISTER_API_INPUT_SCHEMA", - "HTTP_REGISTER_API_OUTPUT_SCHEMA", - "HTTP_UNREGISTER_API_INPUT_SCHEMA", - "HTTP_UNREGISTER_API_OUTPUT_SCHEMA", - "HandlerDescriptor", - "JSONSchema", - "LLM_CHAT_INPUT_SCHEMA", - "LLM_CHAT_OUTPUT_SCHEMA", - "LLM_CHAT_RAW_INPUT_SCHEMA", - "LLM_CHAT_RAW_OUTPUT_SCHEMA", - "LLM_STREAM_CHAT_INPUT_SCHEMA", - "LLM_STREAM_CHAT_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_ACTIVATE_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_ACTIVATE_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_ADD_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_ADD_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_DEACTIVATE_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_DEACTIVATE_OUTPUT_SCHEMA", - "LLM_TOOL_MANAGER_GET_INPUT_SCHEMA", - "LLM_TOOL_MANAGER_GET_OUTPUT_SCHEMA", - "LLM_TOOL_SPEC_SCHEMA", - "LocalFilterRefSpec", - "MEMORY_DELETE_INPUT_SCHEMA", - "MEMORY_DELETE_MANY_INPUT_SCHEMA", - "MEMORY_DELETE_MANY_OUTPUT_SCHEMA", - "MEMORY_DELETE_OUTPUT_SCHEMA", - "MEMORY_GET_INPUT_SCHEMA", - "MEMORY_GET_MANY_INPUT_SCHEMA", - "MEMORY_GET_MANY_OUTPUT_SCHEMA", - "MEMORY_GET_OUTPUT_SCHEMA", - "MEMORY_SAVE_INPUT_SCHEMA", - "MEMORY_SAVE_OUTPUT_SCHEMA", - "MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA", - "MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA", - "MEMORY_SEARCH_INPUT_SCHEMA", - "MEMORY_SEARCH_OUTPUT_SCHEMA", - "MEMORY_STATS_INPUT_SCHEMA", - "MEMORY_STATS_OUTPUT_SCHEMA", - "METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA", - "METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA", - "METADATA_GET_PLUGIN_INPUT_SCHEMA", - "METADATA_GET_PLUGIN_OUTPUT_SCHEMA", - "METADATA_LIST_PLUGINS_INPUT_SCHEMA", - "METADATA_LIST_PLUGINS_OUTPUT_SCHEMA", - "MessageTrigger", - "MessageTypeFilterSpec", - "PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_INPUT_SCHEMA", - "PROVIDER_GET_CURRENT_CHAT_PROVIDER_ID_OUTPUT_SCHEMA", - "PROVIDER_GET_USING_INPUT_SCHEMA", - "PROVIDER_GET_USING_OUTPUT_SCHEMA", - "PROVIDER_LIST_ALL_INPUT_SCHEMA", - "PROVIDER_LIST_ALL_OUTPUT_SCHEMA", - "PROVIDER_META_SCHEMA", - "PLATFORM_GET_GROUP_INPUT_SCHEMA", - "PLATFORM_GET_GROUP_OUTPUT_SCHEMA", - "PLATFORM_GET_MEMBERS_INPUT_SCHEMA", - "PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA", - "PLATFORM_INSTANCE_SCHEMA", - "PLATFORM_LIST_INSTANCES_INPUT_SCHEMA", - "PLATFORM_LIST_INSTANCES_OUTPUT_SCHEMA", - "PLATFORM_SEND_BY_SESSION_INPUT_SCHEMA", - "PLATFORM_SEND_BY_SESSION_OUTPUT_SCHEMA", - "PLATFORM_SEND_CHAIN_INPUT_SCHEMA", - "PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA", - "PLATFORM_SEND_IMAGE_INPUT_SCHEMA", - "PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA", - "PLATFORM_SEND_INPUT_SCHEMA", - "PLATFORM_SEND_OUTPUT_SCHEMA", - "ParamSpec", - "Permissions", - "PlatformFilterSpec", - "REGISTRY_COMMAND_REGISTER_INPUT_SCHEMA", - "REGISTRY_COMMAND_REGISTER_OUTPUT_SCHEMA", - "REGISTRY_GET_HANDLER_BY_FULL_NAME_INPUT_SCHEMA", - "REGISTRY_GET_HANDLER_BY_FULL_NAME_OUTPUT_SCHEMA", - "REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_INPUT_SCHEMA", - "REGISTRY_GET_HANDLERS_BY_EVENT_TYPE_OUTPUT_SCHEMA", - "RESERVED_CAPABILITY_NAMESPACES", - "RESERVED_CAPABILITY_PREFIXES", - "SESSION_PLUGIN_FILTER_HANDLERS_INPUT_SCHEMA", - "SESSION_PLUGIN_FILTER_HANDLERS_OUTPUT_SCHEMA", - "SESSION_PLUGIN_IS_ENABLED_INPUT_SCHEMA", - "SESSION_PLUGIN_IS_ENABLED_OUTPUT_SCHEMA", - "SESSION_REF_SCHEMA", - "SESSION_SERVICE_IS_LLM_ENABLED_INPUT_SCHEMA", - "SESSION_SERVICE_IS_LLM_ENABLED_OUTPUT_SCHEMA", - "SESSION_SERVICE_IS_TTS_ENABLED_INPUT_SCHEMA", - "SESSION_SERVICE_IS_TTS_ENABLED_OUTPUT_SCHEMA", - "SESSION_SERVICE_SET_LLM_STATUS_INPUT_SCHEMA", - "SESSION_SERVICE_SET_LLM_STATUS_OUTPUT_SCHEMA", - "SESSION_SERVICE_SET_TTS_STATUS_INPUT_SCHEMA", - "SESSION_SERVICE_SET_TTS_STATUS_OUTPUT_SCHEMA", - "ScheduleTrigger", - "SessionRef", - "SYSTEM_EVENT_HANDLER_WHITELIST_GET_INPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_GET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_SET_INPUT_SCHEMA", - "SYSTEM_EVENT_HANDLER_WHITELIST_SET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_LLM_GET_STATE_INPUT_SCHEMA", - "SYSTEM_EVENT_LLM_GET_STATE_OUTPUT_SCHEMA", - "SYSTEM_EVENT_LLM_REQUEST_INPUT_SCHEMA", - "SYSTEM_EVENT_LLM_REQUEST_OUTPUT_SCHEMA", - "SYSTEM_EVENT_REACT_INPUT_SCHEMA", - "SYSTEM_EVENT_REACT_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_CLEAR_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_CLEAR_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_GET_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_GET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_SET_INPUT_SCHEMA", - "SYSTEM_EVENT_RESULT_SET_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CHUNK_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CHUNK_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CLOSE_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_CLOSE_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_STREAMING_OUTPUT_SCHEMA", - "SYSTEM_EVENT_SEND_TYPING_INPUT_SCHEMA", - "SYSTEM_EVENT_SEND_TYPING_OUTPUT_SCHEMA", - "Trigger", -] diff --git a/astrbot-sdk/src/astrbot_sdk/protocol/messages.py b/astrbot-sdk/src/astrbot_sdk/protocol/messages.py deleted file mode 100644 index 0bdfe3b59..000000000 --- a/astrbot-sdk/src/astrbot_sdk/protocol/messages.py +++ /dev/null @@ -1,289 +0,0 @@ -"""v4 协议消息模型。 - -这些模型描述的是 `Peer` 与 `Peer` 之间的线协议。握手阶段通过 -`InitializeMessage` 发起,再由 `ResultMessage(kind="initialize_result")` -返回 `InitializeOutput`;能力调用阶段则使用 `InvokeMessage` / `ResultMessage` -或 `EventMessage` 序列。 -""" - -from __future__ import annotations - -import json -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from .descriptors import CapabilityDescriptor, HandlerDescriptor - - -class _MessageBase(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class ErrorPayload(_MessageBase): - """错误载荷,用于 ResultMessage 和 EventMessage 中传递错误信息。 - - Attributes: - code: 错误码,字符串类型,便于语义化错误分类 - message: 错误消息,人类可读的错误描述 - hint: 错误提示,可选的解决方案或建议 - retryable: 是否可重试,标识该错误是否可通过重试解决 - docs_url: 可选的文档链接,帮助调用方定位更多说明 - details: 可选的结构化细节,便于调试和日志展示 - """ - - code: str - message: str - hint: str = "" - retryable: bool = False - docs_url: str = "" - details: dict[str, Any] | None = None - - -class PeerInfo(_MessageBase): - """对等节点信息,标识消息发送方的身份。 - - Attributes: - name: 节点名称,通常是插件 ID 或核心标识 - role: 节点角色,"plugin" 或 "core" - version: 节点版本号,可选 - """ - - name: str - role: Literal["plugin", "core"] - version: str | None = None - - -class InitializeMessage(_MessageBase): - """初始化消息,用于建立连接时交换信息。 - - Attributes: - type: 消息类型,固定为 "initialize" - id: 消息 ID,用于关联响应 - protocol_version: 协议版本号 - peer: 发送方节点信息 - handlers: 注册的处理器描述符列表 - provided_capabilities: 发送方对外暴露的能力描述符列表 - metadata: 扩展元数据,可存储插件配置等信息 - """ - - type: Literal["initialize"] = "initialize" - id: str - protocol_version: str - peer: PeerInfo - handlers: list[HandlerDescriptor] = Field(default_factory=list) - provided_capabilities: list[CapabilityDescriptor] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - - -class InitializeOutput(_MessageBase): - """初始化输出,作为 InitializeMessage 的响应数据。 - - Attributes: - peer: 接收方(核心)节点信息 - protocol_version: 协商后的协议版本;未协商时可为空 - capabilities: 核心提供的能力描述符列表 - metadata: 扩展元数据 - """ - - peer: PeerInfo - protocol_version: str | None = None - capabilities: list[CapabilityDescriptor] = Field(default_factory=list) - metadata: dict[str, Any] = Field(default_factory=dict) - - -class ResultMessage(_MessageBase): - """结果消息,用于返回能力调用的结果。 - - Attributes: - type: 消息类型,固定为 "result" - id: 关联的请求 ID - kind: 结果类型,可选,如 "initialize_result" 标识初始化结果 - success: 是否成功 - output: 成功时的输出数据 - error: 失败时的错误信息 - """ - - type: Literal["result"] = "result" - id: str - kind: str | None = None - success: bool - output: dict[str, Any] = Field(default_factory=dict) - error: ErrorPayload | None = None - - @model_validator(mode="after") - def validate_result_state(self) -> ResultMessage: - """约束 success / output / error 的组合状态。""" - if self.success: - if self.error is not None: - raise ValueError("success=true 时 error 必须为空") - return self - if self.error is None: - raise ValueError("success=false 时必须提供 error") - if self.output: - raise ValueError("success=false 时 output 必须为空") - return self - - -class InvokeMessage(_MessageBase): - """调用消息,用于请求执行远程能力。 - - Attributes: - type: 消息类型,固定为 "invoke" - id: 请求 ID,用于关联响应 - capability: 目标能力名称,格式为 "namespace.action" - input: 调用输入参数 - stream: 是否期望流式响应,若为 True 将收到 EventMessage 序列 - caller_plugin_id: 运行时透传的调用方插件 ID,不属于业务 payload - """ - - type: Literal["invoke"] = "invoke" - id: str - capability: str - input: dict[str, Any] = Field(default_factory=dict) - stream: bool = False - caller_plugin_id: str | None = None - - -class EventMessage(_MessageBase): - """事件消息,用于流式调用的状态通知。 - - 流式调用生命周期: - 1. started: 调用开始,所有字段为空 - 2. delta: 数据增量更新,包含 data 字段 - 3. completed: 调用完成,包含 output 字段 - 4. failed: 调用失败,包含 error 字段 - - Attributes: - type: 消息类型,固定为 "event" - id: 关联的请求 ID - phase: 事件阶段,started/delta/completed/failed - data: 增量数据,仅 delta 阶段有效 - output: 最终输出,仅 completed 阶段有效 - error: 错误信息,仅 failed 阶段有效 - """ - - type: Literal["event"] = "event" - id: str - phase: Literal["started", "delta", "completed", "failed"] - data: dict[str, Any] = Field(default_factory=dict) - output: dict[str, Any] = Field(default_factory=dict) - error: ErrorPayload | None = None - - @model_validator(mode="after") - def validate_phase_constraints(self) -> EventMessage: - """验证各 phase 的字段约束。 - - - started: 所有字段必须为空 - - delta: 必须有 data,output/error 必须为空 - - completed: 必须有 output,data/error 必须为空 - - failed: 必须有 error,data/output 必须为空 - """ - phase = self.phase - if phase == "started": - if self.data or self.output or self.error: - raise ValueError("started phase 必须所有字段为空") - elif phase == "delta": - if not self.data: - raise ValueError("delta phase 需要 data") - if self.output or self.error: - raise ValueError("delta phase 的 output/error 必须为空") - elif phase == "completed": - if not self.output: - raise ValueError("completed phase 需要 output") - if self.data or self.error: - raise ValueError("completed phase 的 data/error 必须为空") - elif phase == "failed": - if self.error is None: - raise ValueError("failed phase 需要 error") - if self.data or self.output: - raise ValueError("failed phase 的 data/output 必须为空") - return self - - -class CancelMessage(_MessageBase): - """取消消息,用于取消正在进行的调用。 - - Attributes: - type: 消息类型,固定为 "cancel" - id: 要取消的请求 ID - reason: 取消原因,默认为 "user_cancelled" - """ - - type: Literal["cancel"] = "cancel" - id: str - reason: str = "user_cancelled" - - -ProtocolMessage = ( - InitializeMessage | ResultMessage | InvokeMessage | EventMessage | CancelMessage -) -"""协议消息联合类型,所有有效消息类型的联合。""" - -_PROTOCOL_MESSAGE_MODELS = { - "initialize": InitializeMessage, - "result": ResultMessage, - "invoke": InvokeMessage, - "event": EventMessage, - "cancel": CancelMessage, -} - - -def parse_message( - payload: ProtocolMessage | str | bytes | dict[str, Any], -) -> ProtocolMessage: - """解析协议消息。 - - 从原始载荷(字符串、字节或字典)解析为对应的 ProtocolMessage 类型。 - 根据 "type" 字段自动识别消息类型并验证。 - - Args: - payload: 原始消息载荷,支持已解析模型、JSON 字符串、字节或字典 - - Returns: - 解析后的协议消息对象 - - Raises: - ValueError: 未知的消息类型 - - Example: - >>> msg = parse_message('{"type": "invoke", "id": "1", "capability": "test"}') - >>> isinstance(msg, InvokeMessage) - True - """ - if isinstance( - payload, - ( - InitializeMessage, - ResultMessage, - InvokeMessage, - EventMessage, - CancelMessage, - ), - ): - return payload - if isinstance(payload, bytes): - payload = payload.decode("utf-8") - if isinstance(payload, str): - payload = json.loads(payload) - if not isinstance(payload, dict): - raise ValueError("协议消息必须是 JSON object") - message_type = payload.get("type") - model = _PROTOCOL_MESSAGE_MODELS.get(str(message_type)) - if model is not None: - return model.model_validate(payload) - raise ValueError(f"未知消息类型:{message_type}") - - -__all__ = [ - "CancelMessage", - "ErrorPayload", - "EventMessage", - "InitializeMessage", - "InitializeOutput", - "InvokeMessage", - "PeerInfo", - "ProtocolMessage", - "ResultMessage", - "parse_message", -] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/__init__.py b/astrbot-sdk/src/astrbot_sdk/runtime/__init__.py deleted file mode 100644 index 7601f745c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""AstrBot SDK runtime public exports. - -本模块提供运行时核心组件的公共导出,包括: -- CapabilityRouter: 能力路由器,处理能力调用的分发和路由 -- HandlerDispatcher: 事件处理器分发器,将事件分发到注册的 handler -- Peer: 与 AstrBot 核心通信的对等端抽象 -- Transport 系列: 进程间通信传输层实现(stdio/websocket) - -延迟加载策略: -为避免导入时触发 websocket/aiohttp 等重型依赖,采用 __getattr__ 实现按需加载。 -这样轻量级导入(如仅使用类型提示)不会产生不必要的依赖开销。 -""" - -from __future__ import annotations - -from importlib import import_module -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .capability_router import CapabilityRouter, StreamExecution - from .handler_dispatcher import HandlerDispatcher - from .peer import Peer - from .transport import ( - MessageHandler, - StdioTransport, - Transport, - WebSocketClientTransport, - WebSocketServerTransport, - ) - -__all__ = [ - "CapabilityRouter", - "HandlerDispatcher", - "MessageHandler", - "Peer", - "StdioTransport", - "StreamExecution", - "Transport", - "WebSocketClientTransport", - "WebSocketServerTransport", -] - - -def __getattr__(name: str) -> Any: - if name in {"CapabilityRouter", "StreamExecution"}: - module = import_module(".capability_router", __name__) - return getattr(module, name) - if name == "HandlerDispatcher": - module = import_module(".handler_dispatcher", __name__) - return getattr(module, name) - if name == "Peer": - module = import_module(".peer", __name__) - return getattr(module, name) - if name in { - "MessageHandler", - "StdioTransport", - "Transport", - "WebSocketClientTransport", - "WebSocketServerTransport", - }: - module = import_module(".transport", __name__) - return getattr(module, name) - raise AttributeError(name) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/__init__.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/__init__.py deleted file mode 100644 index c9c34acae..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/__init__.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from .bridge_base import CapabilityRouterBridgeBase -from .capabilities import ( - ConversationCapabilityMixin, - DBCapabilityMixin, - HttpCapabilityMixin, - KnowledgeBaseCapabilityMixin, - LLMCapabilityMixin, - McpCapabilityMixin, - MemoryCapabilityMixin, - MessageHistoryCapabilityMixin, - MetadataCapabilityMixin, - PersonaCapabilityMixin, - PlatformCapabilityMixin, - ProviderCapabilityMixin, - SessionCapabilityMixin, - SkillCapabilityMixin, - SystemCapabilityMixin, -) - - -class BuiltinCapabilityRouterMixin( - LLMCapabilityMixin, - MemoryCapabilityMixin, - DBCapabilityMixin, - PlatformCapabilityMixin, - HttpCapabilityMixin, - MetadataCapabilityMixin, - ProviderCapabilityMixin, - McpCapabilityMixin, - SessionCapabilityMixin, - SkillCapabilityMixin, - PersonaCapabilityMixin, - ConversationCapabilityMixin, - MessageHistoryCapabilityMixin, - KnowledgeBaseCapabilityMixin, - SystemCapabilityMixin, - CapabilityRouterBridgeBase, -): - def _register_builtin_capabilities(self) -> None: - self._register_llm_capabilities() - self._register_memory_capabilities() - self._register_db_capabilities() - self._register_platform_capabilities() - self._register_http_capabilities() - self._register_metadata_capabilities() - self._register_provider_capabilities() - self._register_agent_tool_capabilities() - self._register_mcp_capabilities() - self._register_session_capabilities() - self._register_skill_capabilities() - self._register_persona_capabilities() - self._register_conversation_capabilities() - self._register_message_history_capabilities() - self._register_kb_capabilities() - self._register_provider_manager_capabilities() - self._register_platform_manager_capabilities() - self._register_system_capabilities() - - -__all__ = ["BuiltinCapabilityRouterMixin"] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/_host.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/_host.py deleted file mode 100644 index 2fd016e7c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/_host.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import datetime -from pathlib import Path -from typing import Any - -from ...protocol.descriptors import CapabilityDescriptor - - -class CapabilityRouterHost: - memory_store: dict[str, dict[str, Any]] - _memory_backends: dict[str, Any] - _memory_index: dict[str, dict[str, Any]] - _memory_dirty_keys: set[str] - _memory_expires_at: dict[str, datetime | None] - db_store: dict[str, Any] - sent_messages: list[dict[str, Any]] - event_actions: list[dict[str, Any]] - http_api_store: list[dict[str, Any]] - _event_streams: dict[str, dict[str, Any]] - _plugins: dict[str, Any] - _request_overlays: dict[str, dict[str, Any]] - _provider_catalog: dict[str, list[dict[str, Any]]] - _provider_configs: dict[str, dict[str, Any]] - _active_provider_ids: dict[str, str | None] - _provider_change_subscriptions: dict[str, asyncio.Queue[dict[str, Any]]] - _system_data_root: Path - _session_waiters: dict[str, set[str]] - _session_plugin_configs: dict[str, dict[str, Any]] - _session_service_configs: dict[str, dict[str, Any]] - _db_watch_subscriptions: dict[str, tuple[str | None, asyncio.Queue[dict[str, Any]]]] - _dynamic_command_routes: dict[str, list[dict[str, Any]]] - _file_token_store: dict[str, str] - _platform_instances: list[dict[str, Any]] - _persona_store: dict[str, dict[str, Any]] - _conversation_store: dict[str, dict[str, Any]] - _session_current_conversation_ids: dict[str, str] - _kb_store: dict[str, dict[str, Any]] - _kb_document_store: dict[str, dict[str, dict[str, Any]]] - _kb_document_content_store: dict[str, str] - - def register( - self, - descriptor: CapabilityDescriptor, - *, - call_handler=None, - stream_handler=None, - finalize=None, - exposed: bool = True, - ) -> None: - raise NotImplementedError - - def _emit_db_change(self, *, op: str, key: str, value: Any | None) -> None: - raise NotImplementedError - - @staticmethod - def _require_caller_plugin_id(capability_name: str) -> str: - raise NotImplementedError - - @staticmethod - def _validated_plugin_id(plugin_id: str, *, capability_name: str) -> str: - raise NotImplementedError - - def _plugin_data_dir(self, plugin_id: str, *, capability_name: str) -> Path: - raise NotImplementedError - - def register_dynamic_command_route( - self, - *, - plugin_id: str, - command_name: str, - handler_full_name: str, - desc: str = "", - priority: int = 0, - use_regex: bool = False, - ) -> None: - raise NotImplementedError - - def get_platform_instances(self) -> list[dict[str, Any]]: - raise NotImplementedError - - def _register_agent_tool_capabilities(self) -> None: - raise NotImplementedError - - def _provider_entry( - self, - payload: dict[str, Any], - capability_name: str, - expected_kind: str | None = None, - ) -> dict[str, Any]: - raise NotImplementedError - - async def _provider_embedding_get_embedding( - self, request_id: str, payload: dict[str, Any], token - ) -> dict[str, Any]: - raise NotImplementedError - - async def _provider_embedding_get_embeddings( - self, request_id: str, payload: dict[str, Any], token - ) -> dict[str, Any]: - raise NotImplementedError diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/bridge_base.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/bridge_base.py deleted file mode 100644 index 0e26a05d7..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/bridge_base.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -import copy -import hashlib -import math -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from ..._internal.plugin_ids import resolve_plugin_data_dir, validate_plugin_id -from ...errors import AstrBotError -from ...protocol.descriptors import ( - BUILTIN_CAPABILITY_SCHEMAS, - CapabilityDescriptor, - SessionRef, -) -from ._host import CapabilityRouterHost - - -def _clone_target_payload(value: Any) -> dict[str, Any] | None: - if not isinstance(value, dict): - return None - return {str(key): item for key, item in value.items()} - - -def _clone_chain_payload(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - return [ - {str(key): item for key, item in chunk.items()} - for chunk in value - if isinstance(chunk, dict) - ] - - -_MOCK_EMBEDDING_DIM = 24 - - -def _embedding_terms(text: str) -> list[str]: - """Build stable tokens for the mock embedding implementation.""" - normalized = re.sub(r"\s+", " ", str(text).strip().casefold()) - compact = normalized.replace(" ", "") - if not normalized: - return [] - - terms = [word for word in re.findall(r"\w+", normalized, flags=re.UNICODE) if word] - if compact: - if len(compact) == 1: - terms.append(compact) - else: - terms.extend( - compact[index : index + 2] for index in range(len(compact) - 1) - ) - terms.append(compact) - return terms or [normalized] - - -def _mock_embedding_vector(text: str, *, provider_id: str) -> list[float]: - """Generate a deterministic normalized mock embedding vector.""" - values = [0.0] * _MOCK_EMBEDDING_DIM - for term in _embedding_terms(text): - digest = hashlib.sha256(f"{provider_id}:{term}".encode()).digest() - index = int.from_bytes(digest[:2], "big") % _MOCK_EMBEDDING_DIM - values[index] += 1.0 + min(len(term), 8) * 0.05 - norm = math.sqrt(sum(value * value for value in values)) - if norm <= 0: - return values - return [value / norm for value in values] - - -class CapabilityRouterBridgeBase(CapabilityRouterHost): - _memory_backends: dict[str, Any] - - @staticmethod - def _validated_plugin_id(plugin_id: str, *, capability_name: str) -> str: - try: - return validate_plugin_id(plugin_id) - except ValueError as exc: - raise AstrBotError.invalid_input( - f"{capability_name} requires a safe plugin_id: {exc}" - ) from exc - - def _plugin_data_dir(self, plugin_id: str, *, capability_name: str) -> Path: - try: - return resolve_plugin_data_dir(self._system_data_root, plugin_id) - except ValueError as exc: - raise AstrBotError.invalid_input( - f"{capability_name} requires a safe plugin_id: {exc}" - ) from exc - - def _builtin_descriptor( - self, - name: str, - description: str, - *, - supports_stream: bool = False, - cancelable: bool = False, - ) -> CapabilityDescriptor: - schema = BUILTIN_CAPABILITY_SCHEMAS[name] - return CapabilityDescriptor( - name=name, - description=description, - input_schema=copy.deepcopy(schema["input"]), - output_schema=copy.deepcopy(schema["output"]), - supports_stream=supports_stream, - cancelable=cancelable, - ) - - def _resolve_target( - self, payload: dict[str, Any] - ) -> tuple[str, dict[str, Any] | None]: - target_payload = payload.get("target") - if isinstance(target_payload, dict): - target = SessionRef.model_validate(target_payload) - return target.session, target.to_payload() - return str(payload.get("session", "")), None - - @staticmethod - def _is_group_session(session: str) -> bool: - normalized = str(session).lower() - return ":group:" in normalized or ":groupmessage:" in normalized - - @staticmethod - def _mock_group_payload(session: str) -> dict[str, Any] | None: - if not CapabilityRouterBridgeBase._is_group_session(session): - return None - members = [ - { - "user_id": f"{session}:member-1", - "nickname": "Member 1", - "role": "member", - }, - { - "user_id": f"{session}:member-2", - "nickname": "Member 2", - "role": "admin", - }, - ] - return { - "group_id": session.rsplit(":", maxsplit=1)[-1], - "group_name": f"Mock Group {session.rsplit(':', maxsplit=1)[-1]}", - "group_avatar": "", - "group_owner": members[0]["user_id"], - "group_admins": [members[1]["user_id"]], - "members": members, - } - - def _session_plugin_config(self, session: str) -> dict[str, Any]: - config = self._session_plugin_configs.get(str(session), {}) - return dict(config) if isinstance(config, dict) else {} - - def _session_service_config(self, session: str) -> dict[str, Any]: - config = self._session_service_configs.get(str(session), {}) - return dict(config) if isinstance(config, dict) else {} - - @staticmethod - def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - @staticmethod - def _session_platform_id(session: str) -> str: - parts = str(session).split(":", maxsplit=1) - if parts and parts[0].strip(): - return parts[0].strip() - return "unknown" - - @staticmethod - def _normalize_history_payload(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - return [dict(item) for item in value if isinstance(item, dict)] - - @staticmethod - def _normalize_persona_dialogs_payload(value: Any) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value if isinstance(item, str)] - - @staticmethod - def _optional_int(value: Any) -> int | None: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/__init__.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/__init__.py deleted file mode 100644 index 99b468b8d..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from .conversation import ConversationCapabilityMixin -from .db import DBCapabilityMixin -from .http import HttpCapabilityMixin -from .kb import KnowledgeBaseCapabilityMixin -from .llm import LLMCapabilityMixin -from .mcp import McpCapabilityMixin -from .memory import MemoryCapabilityMixin -from .message_history import MessageHistoryCapabilityMixin -from .metadata import MetadataCapabilityMixin -from .persona import PersonaCapabilityMixin -from .platform import PlatformCapabilityMixin -from .provider import ProviderCapabilityMixin -from .session import SessionCapabilityMixin -from .skill import SkillCapabilityMixin -from .system import SystemCapabilityMixin - -__all__ = [ - "ConversationCapabilityMixin", - "DBCapabilityMixin", - "HttpCapabilityMixin", - "KnowledgeBaseCapabilityMixin", - "LLMCapabilityMixin", - "McpCapabilityMixin", - "MemoryCapabilityMixin", - "MessageHistoryCapabilityMixin", - "MetadataCapabilityMixin", - "PersonaCapabilityMixin", - "PlatformCapabilityMixin", - "ProviderCapabilityMixin", - "SessionCapabilityMixin", - "SkillCapabilityMixin", - "SystemCapabilityMixin", -] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/conversation.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/conversation.py deleted file mode 100644 index a250f43e5..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/conversation.py +++ /dev/null @@ -1,261 +0,0 @@ -from __future__ import annotations - -import uuid -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class ConversationCapabilityMixin(CapabilityRouterBridgeBase): - async def _conversation_new( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - if not session: - raise AstrBotError.invalid_input("conversation.new requires session") - raw_conversation = payload.get("conversation") - if raw_conversation is None: - raw_conversation = {} - if not isinstance(raw_conversation, dict): - raise AstrBotError.invalid_input( - "conversation.new requires conversation object" - ) - conversation_id = uuid.uuid4().hex - now = self._now_iso() - record = { - "conversation_id": conversation_id, - "session": session, - "platform_id": ( - str(raw_conversation.get("platform_id")) - if raw_conversation.get("platform_id") is not None - else self._session_platform_id(session) - ), - "history": self._normalize_history_payload(raw_conversation.get("history")), - "title": ( - str(raw_conversation.get("title")) - if raw_conversation.get("title") is not None - else None - ), - "persona_id": ( - str(raw_conversation.get("persona_id")) - if raw_conversation.get("persona_id") is not None - else None - ), - "created_at": now, - "updated_at": now, - "token_usage": None, - } - self._conversation_store[conversation_id] = record - self._session_current_conversation_ids[session] = conversation_id - return {"conversation_id": conversation_id} - - async def _conversation_switch( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = str(payload.get("conversation_id", "")).strip() - record = self._conversation_store.get(conversation_id) - if record is None or str(record.get("session", "")) != session: - raise AstrBotError.invalid_input( - "conversation.switch requires a conversation in the same session" - ) - self._session_current_conversation_ids[session] = conversation_id - return {} - - async def _conversation_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = payload.get("conversation_id") - normalized_conversation_id = ( - str(conversation_id).strip() if conversation_id is not None else "" - ) - if not normalized_conversation_id: - normalized_conversation_id = self._session_current_conversation_ids.get( - session, "" - ) - if not normalized_conversation_id: - return {} - record = self._conversation_store.get(normalized_conversation_id) - if record is None: - return {} - if str(record.get("session", "")) != session: - raise AstrBotError.invalid_input( - "conversation.delete requires a conversation in the same session" - ) - del self._conversation_store[normalized_conversation_id] - current_conversation_id = self._session_current_conversation_ids.get(session) - if current_conversation_id == normalized_conversation_id: - replacement = next( - ( - conversation_id - for conversation_id, item in self._conversation_store.items() - if str(item.get("session", "")) == session - ), - None, - ) - if replacement is None: - self._session_current_conversation_ids.pop(session, None) - else: - self._session_current_conversation_ids[session] = replacement - return {} - - async def _conversation_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = str(payload.get("conversation_id", "")).strip() - record = self._conversation_store.get(conversation_id) - if record is None and bool(payload.get("create_if_not_exists", False)): - created = await self._conversation_new( - _request_id, - {"session": session, "conversation": {}}, - _token, - ) - record = self._conversation_store.get( - str(created.get("conversation_id", "")).strip() - ) - if record is None: - return {"conversation": None} - if str(record.get("session", "")) != session: - return {"conversation": None} - return {"conversation": dict(record)} - - async def _conversation_get_current( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = self._session_current_conversation_ids.get(session, "") - if not conversation_id and bool(payload.get("create_if_not_exists", False)): - created = await self._conversation_new( - _request_id, - {"session": session, "conversation": {}}, - _token, - ) - conversation_id = str(created.get("conversation_id", "")).strip() - if not conversation_id: - return {"conversation": None} - record = self._conversation_store.get(conversation_id) - if record is None or str(record.get("session", "")) != session: - return {"conversation": None} - return {"conversation": dict(record)} - - async def _conversation_list( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = payload.get("session") - platform_id = payload.get("platform_id") - conversations = [] - for conversation_id in sorted(self._conversation_store.keys()): - item = self._conversation_store[conversation_id] - if session is not None and str(item.get("session", "")) != str(session): - continue - if platform_id is not None and str(item.get("platform_id", "")) != str( - platform_id - ): - continue - conversations.append(dict(item)) - return {"conversations": conversations} - - async def _conversation_update( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = payload.get("conversation_id") - normalized_conversation_id = ( - str(conversation_id).strip() if conversation_id is not None else "" - ) - if not normalized_conversation_id: - normalized_conversation_id = self._session_current_conversation_ids.get( - session, "" - ) - if not normalized_conversation_id: - return {} - record = self._conversation_store.get(normalized_conversation_id) - if record is None: - return {} - if str(record.get("session", "")) != session: - raise AstrBotError.invalid_input( - "conversation.update requires a conversation in the same session" - ) - raw_conversation = payload.get("conversation") - if not isinstance(raw_conversation, dict): - raw_conversation = {} - if "history" in raw_conversation: - history = raw_conversation.get("history") - record["history"] = ( - self._normalize_history_payload(history) if history is not None else [] - ) - if "title" in raw_conversation: - title = raw_conversation.get("title") - record["title"] = str(title) if title is not None else None - if "persona_id" in raw_conversation: - persona_id = raw_conversation.get("persona_id") - record["persona_id"] = str(persona_id) if persona_id is not None else None - if "token_usage" in raw_conversation: - token_usage = raw_conversation.get("token_usage") - record["token_usage"] = ( - int(token_usage) if token_usage is not None else None - ) - record["updated_at"] = self._now_iso() - return {} - - async def _conversation_unset_persona( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")).strip() - conversation_id = payload.get("conversation_id") - normalized_conversation_id = ( - str(conversation_id).strip() if conversation_id is not None else "" - ) - if not normalized_conversation_id: - normalized_conversation_id = self._session_current_conversation_ids.get( - session, "" - ) - if not normalized_conversation_id: - return {} - record = self._conversation_store.get(normalized_conversation_id) - if record is None: - return {} - if str(record.get("session", "")) != session: - raise AstrBotError.invalid_input( - "conversation.unset_persona requires a conversation in the same session" - ) - record["persona_id"] = None - record["updated_at"] = self._now_iso() - return {} - - def _register_conversation_capabilities(self) -> None: - self.register( - self._builtin_descriptor("conversation.new", "新建对话"), - call_handler=self._conversation_new, - ) - self.register( - self._builtin_descriptor("conversation.switch", "切换对话"), - call_handler=self._conversation_switch, - ) - self.register( - self._builtin_descriptor("conversation.delete", "删除对话"), - call_handler=self._conversation_delete, - ) - self.register( - self._builtin_descriptor("conversation.get", "获取对话"), - call_handler=self._conversation_get, - ) - self.register( - self._builtin_descriptor("conversation.get_current", "获取当前对话"), - call_handler=self._conversation_get_current, - ) - self.register( - self._builtin_descriptor("conversation.list", "列出对话"), - call_handler=self._conversation_list, - ) - self.register( - self._builtin_descriptor("conversation.update", "更新对话"), - call_handler=self._conversation_update, - ) - self.register( - self._builtin_descriptor("conversation.unset_persona", "清空对话人格"), - call_handler=self._conversation_unset_persona, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/db.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/db.py deleted file mode 100644 index f8bdfedf9..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/db.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from typing import Any - -from ....errors import AstrBotError -from ..._streaming import StreamExecution -from ..bridge_base import CapabilityRouterBridgeBase - - -class DBCapabilityMixin(CapabilityRouterBridgeBase): - def _db_scoped_key(self, plugin_id: str, key: str) -> str: - """将用户提供的 key 加上插件命名空间前缀,防止跨插件越权访问。""" - return f"{plugin_id}:{key}" - - def _db_strip_scope(self, plugin_id: str, scoped_key: str) -> str: - """去掉命名空间前缀,返回插件视角的原始 key。""" - prefix = f"{plugin_id}:" - return ( - scoped_key[len(prefix) :] if scoped_key.startswith(prefix) else scoped_key - ) - - def _db_public_event( - self, plugin_id: str, raw_event: dict[str, Any] - ) -> dict[str, Any]: - """将内部事件转换回插件可见的 key 视图。""" - event = dict(raw_event) - key = event.get("key") - if isinstance(key, str): - event["key"] = self._db_strip_scope(plugin_id, key) - return event - - async def _db_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.get") - key = self._db_scoped_key(plugin_id, str(payload.get("key", ""))) - return {"value": self.db_store.get(key)} - - async def _db_set( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.set") - key = self._db_scoped_key(plugin_id, str(payload.get("key", ""))) - value = payload.get("value") - self.db_store[key] = value - self._emit_db_change(op="set", key=key, value=value) - return {} - - async def _db_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.delete") - key = self._db_scoped_key(plugin_id, str(payload.get("key", ""))) - self.db_store.pop(key, None) - self._emit_db_change(op="delete", key=key, value=None) - return {} - - async def _db_list( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.list") - ns_prefix = f"{plugin_id}:" - # 只列出属于当前插件命名空间的 key,并去掉命名空间前缀返回给插件 - user_prefix = payload.get("prefix") - all_keys = sorted( - key for key in self.db_store.keys() if key.startswith(ns_prefix) - ) - stripped = [self._db_strip_scope(plugin_id, k) for k in all_keys] - if isinstance(user_prefix, str): - stripped = [k for k in stripped if k.startswith(user_prefix)] - return {"keys": stripped} - - async def _db_get_many( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.get_many") - keys_payload = payload.get("keys") - if not isinstance(keys_payload, (list, tuple)): - raise AstrBotError.invalid_input("db.get_many 的 keys 必须是数组") - items = [ - { - "key": str(k), - "value": self.db_store.get(self._db_scoped_key(plugin_id, str(k))), - } - for k in keys_payload - ] - return {"items": items} - - async def _db_set_many( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("db.set_many") - items_payload = payload.get("items") - if not isinstance(items_payload, (list, tuple)): - raise AstrBotError.invalid_input("db.set_many 的 items 必须是数组") - for entry in items_payload: - if not isinstance(entry, dict): - raise AstrBotError.invalid_input( - "db.set_many 的 items 必须是 object 数组" - ) - key = self._db_scoped_key(plugin_id, str(entry.get("key", ""))) - value = entry.get("value") - self.db_store[key] = value - self._emit_db_change(op="set", key=key, value=value) - return {} - - async def _db_watch( - self, request_id: str, payload: dict[str, Any], _token - ) -> StreamExecution: - plugin_id = self._require_caller_plugin_id("db.watch") - prefix = payload.get("prefix") - prefix_value: str | None - if isinstance(prefix, str): - # 将用户传入的前缀也加上命名空间,只监听本插件的 key 变更 - prefix_value = self._db_scoped_key(plugin_id, prefix) - elif prefix is None: - # 无前缀时默认监听整个命名空间 - prefix_value = f"{plugin_id}:" - else: - raise AstrBotError.invalid_input("db.watch 的 prefix 必须是 string 或 null") - - queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - self._db_watch_subscriptions[request_id] = (prefix_value, queue) - - async def iterator() -> AsyncIterator[dict[str, Any]]: - try: - while True: - yield self._db_public_event(plugin_id, await queue.get()) - finally: - self._db_watch_subscriptions.pop(request_id, None) - - return StreamExecution( - iterator=iterator(), - finalize=lambda _chunks: {}, - collect_chunks=False, - ) - - def _register_db_capabilities(self) -> None: - self.register( - self._builtin_descriptor("db.get", "读取 KV"), call_handler=self._db_get - ) - self.register( - self._builtin_descriptor("db.set", "写入 KV"), call_handler=self._db_set - ) - self.register( - self._builtin_descriptor("db.delete", "删除 KV"), - call_handler=self._db_delete, - ) - self.register( - self._builtin_descriptor("db.list", "列出 KV"), call_handler=self._db_list - ) - self.register( - self._builtin_descriptor("db.get_many", "批量读取 KV"), - call_handler=self._db_get_many, - ) - self.register( - self._builtin_descriptor("db.set_many", "批量写入 KV"), - call_handler=self._db_set_many, - ) - self.register( - self._builtin_descriptor( - "db.watch", - "订阅 KV 变更", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._db_watch, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/http.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/http.py deleted file mode 100644 index ba12db868..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/http.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - -# 路由只允许字母、数字、/, -, _, . 以及路径参数 {param},且必须以 / 开头 -# 禁止 .. 防止路径遍历,禁止连续斜杠 -_ROUTE_SAFE_RE = re.compile(r"^(/[\w\-._{}]*)+$") - - -def _validate_route(route: str, capability_name: str) -> None: - """校验 HTTP 路由路径格式,阻止路径遍历和非法字符。""" - if ".." in route: - raise AstrBotError.invalid_input(f"{capability_name}: 路由路径不允许包含 '..'") - if not _ROUTE_SAFE_RE.match(route): - raise AstrBotError.invalid_input( - f"{capability_name}: 路由路径格式非法,只允许字母/数字/-/_/./{{param}} 段," - "且必须以 / 开头,如 /foo/bar" - ) - - -class HttpCapabilityMixin(CapabilityRouterBridgeBase): - async def _http_register_api( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - methods_payload = payload.get("methods") - if not isinstance(methods_payload, list) or not all( - isinstance(item, str) for item in methods_payload - ): - raise AstrBotError.invalid_input( - "http.register_api 的 methods 必须是 string 数组" - ) - route = str(payload.get("route", "")).strip() - handler_capability = str(payload.get("handler_capability", "")).strip() - if not route or not handler_capability: - raise AstrBotError.invalid_input( - "http.register_api 需要 route 和 handler_capability" - ) - _validate_route(route, "http.register_api") - plugin_name = self._require_caller_plugin_id("http.register_api") - methods = sorted({method.upper() for method in methods_payload if method}) - entry: dict[str, Any] = { - "route": route, - "methods": methods, - "handler_capability": handler_capability, - "description": str(payload.get("description", "")), - "plugin_id": plugin_name, - } - self.http_api_store = [ - item - for item in self.http_api_store - if not ( - item.get("route") == route - and item.get("plugin_id") == entry["plugin_id"] - and item.get("methods") == methods - ) - ] - self.http_api_store.append(entry) - return {} - - async def _http_unregister_api( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - route = str(payload.get("route", "")).strip() - methods_payload = payload.get("methods") - if not isinstance(methods_payload, list) or not all( - isinstance(item, str) for item in methods_payload - ): - raise AstrBotError.invalid_input( - "http.unregister_api 的 methods 必须是 string 数组" - ) - plugin_name = self._require_caller_plugin_id("http.unregister_api") - methods = {method.upper() for method in methods_payload if method} - updated: list[dict[str, Any]] = [] - for entry in self.http_api_store: - if entry.get("route") != route: - updated.append(entry) - continue - if entry.get("plugin_id") != plugin_name: - updated.append(entry) - continue - if not methods: - # `HTTPClient.unregister_api(methods=None)` 会归一化为空列表, - # 公开语义就是“移除当前插件在该 route 下注册的全部方法”。 - continue - remaining_methods = [ - method for method in entry.get("methods", []) if method not in methods - ] - if remaining_methods: - updated.append({**entry, "methods": remaining_methods}) - self.http_api_store = updated - return {} - - async def _http_list_apis( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_name = self._require_caller_plugin_id("http.list_apis") - apis = [ - dict(entry) - for entry in self.http_api_store - if entry.get("plugin_id") == plugin_name - ] - return {"apis": apis} - - def _register_http_capabilities(self) -> None: - self.register( - self._builtin_descriptor("http.register_api", "注册 HTTP 路由"), - call_handler=self._http_register_api, - ) - self.register( - self._builtin_descriptor("http.unregister_api", "注销 HTTP 路由"), - call_handler=self._http_unregister_api, - ) - self.register( - self._builtin_descriptor("http.list_apis", "列出 HTTP 路由"), - call_handler=self._http_list_apis, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/kb.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/kb.py deleted file mode 100644 index 77a03d86c..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/kb.py +++ /dev/null @@ -1,427 +0,0 @@ -from __future__ import annotations - -import math -import uuid -from pathlib import Path -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -def _term_set(text: str) -> set[str]: - normalized = " ".join(str(text).strip().casefold().split()) - compact = normalized.replace(" ", "") - if not normalized: - return set() - terms = {item for item in normalized.split(" ") if item} - if compact: - terms.add(compact) - if len(compact) > 1: - terms.update( - compact[index : index + 2] for index in range(len(compact) - 1) - ) - return terms - - -class KnowledgeBaseCapabilityMixin(CapabilityRouterBridgeBase): - def _kb_documents(self, kb_id: str) -> dict[str, dict[str, Any]]: - return self._kb_document_store.setdefault(kb_id, {}) - - def _refresh_mock_kb_stats(self, kb_id: str) -> None: - kb = self._kb_store.get(kb_id) - if not isinstance(kb, dict): - return - documents = self._kb_documents(kb_id) - kb["doc_count"] = len(documents) - kb["chunk_count"] = sum( - int(document.get("chunk_count", 0) or 0) for document in documents.values() - ) - kb["updated_at"] = self._now_iso() - - def _resolve_mock_kb_ids(self, payload: dict[str, Any]) -> list[str]: - kb_ids = [ - str(item).strip() for item in payload.get("kb_ids", []) if str(item).strip() - ] - if kb_ids: - return [kb_id for kb_id in kb_ids if kb_id in self._kb_store] - - kb_names = [ - str(item).strip() - for item in payload.get("kb_names", []) - if str(item).strip() - ] - if not kb_names: - return [] - name_set = set(kb_names) - return [ - kb_id - for kb_id, kb in self._kb_store.items() - if str(kb.get("kb_name", "")).strip() in name_set - ] - - @staticmethod - def _score_mock_document(query: str, content: str) -> float: - query_terms = _term_set(query) - content_terms = _term_set(content) - if not query_terms or not content_terms: - return 0.0 - overlap = len(query_terms & content_terms) - if overlap <= 0: - return 0.0 - score = overlap / len(query_terms) - if query.strip().casefold() in str(content).casefold(): - score += 0.25 - return min(score, 1.0) - - @staticmethod - def _build_mock_context_text(results: list[dict[str, Any]]) -> str: - lines = ["以下是相关的知识库内容,请参考这些信息回答用户的问题:\n"] - for index, item in enumerate(results, start=1): - lines.append(f"【知识 {index}】") - lines.append(f"来源: {item['kb_name']} / {item['doc_name']}") - lines.append(f"内容: {item['content']}") - lines.append(f"相关度: {float(item['score']):.2f}") - lines.append("") - return "\n".join(lines) - - async def _kb_list( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return { - "kbs": [ - dict(record) - for record in sorted( - self._kb_store.values(), - key=lambda item: str(item.get("created_at", "")), - ) - ] - } - - async def _kb_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - record = self._kb_store.get(kb_id) - return {"kb": dict(record) if isinstance(record, dict) else None} - - async def _kb_create( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - raw_kb = payload.get("kb") - if not isinstance(raw_kb, dict): - raise AstrBotError.invalid_input("kb.create requires kb object") - embedding_provider_id = str(raw_kb.get("embedding_provider_id", "")).strip() - if not embedding_provider_id: - raise AstrBotError.invalid_input("kb.create requires embedding_provider_id") - kb_id = uuid.uuid4().hex - now = self._now_iso() - record = { - "kb_id": kb_id, - "kb_name": str(raw_kb.get("kb_name", "")), - "description": ( - str(raw_kb.get("description")) - if raw_kb.get("description") is not None - else None - ), - "emoji": ( - str(raw_kb.get("emoji")) if raw_kb.get("emoji") is not None else None - ), - "embedding_provider_id": embedding_provider_id, - "rerank_provider_id": ( - str(raw_kb.get("rerank_provider_id")) - if raw_kb.get("rerank_provider_id") is not None - else None - ), - "chunk_size": self._optional_int(raw_kb.get("chunk_size")), - "chunk_overlap": self._optional_int(raw_kb.get("chunk_overlap")), - "top_k_dense": self._optional_int(raw_kb.get("top_k_dense")), - "top_k_sparse": self._optional_int(raw_kb.get("top_k_sparse")), - "top_m_final": self._optional_int(raw_kb.get("top_m_final")), - "doc_count": 0, - "chunk_count": 0, - "created_at": now, - "updated_at": now, - } - self._kb_store[kb_id] = record - self._kb_document_store[kb_id] = {} - return {"kb": dict(record)} - - async def _kb_update( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - raw_kb = payload.get("kb") - if not isinstance(raw_kb, dict): - raise AstrBotError.invalid_input("kb.update requires kb object") - record = self._kb_store.get(kb_id) - if not isinstance(record, dict): - return {"kb": None} - - for field_name in ( - "kb_name", - "description", - "emoji", - "embedding_provider_id", - "rerank_provider_id", - ): - if field_name in raw_kb: - value = raw_kb.get(field_name) - record[field_name] = str(value) if value is not None else None - for field_name in ( - "chunk_size", - "chunk_overlap", - "top_k_dense", - "top_k_sparse", - "top_m_final", - ): - if field_name in raw_kb: - record[field_name] = self._optional_int(raw_kb.get(field_name)) - record["updated_at"] = self._now_iso() - return {"kb": dict(record)} - - async def _kb_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - documents = self._kb_document_store.pop(kb_id, {}) - for document in documents.values(): - doc_id = str(document.get("doc_id", "")).strip() - if doc_id: - self._kb_document_content_store.pop(doc_id, None) - deleted = self._kb_store.pop(kb_id, None) is not None - return {"deleted": deleted} - - async def _kb_retrieve( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - query = str(payload.get("query", "")).strip() - if not query: - raise AstrBotError.invalid_input("kb.retrieve requires query") - kb_ids = self._resolve_mock_kb_ids(payload) - if not kb_ids: - raise AstrBotError.invalid_input("kb.retrieve requires kb_ids or kb_names") - - top_m_final = self._optional_int(payload.get("top_m_final")) or 5 - results: list[dict[str, Any]] = [] - for kb_id in kb_ids: - kb = self._kb_store.get(kb_id) - if not isinstance(kb, dict): - continue - for document in self._kb_documents(kb_id).values(): - doc_id = str(document.get("doc_id", "")).strip() - if not doc_id: - continue - content = self._kb_document_content_store.get(doc_id, "") - score = self._score_mock_document(query, content) - if score <= 0: - continue - results.append( - { - "chunk_id": f"{doc_id}:0", - "doc_id": doc_id, - "kb_id": kb_id, - "kb_name": str(kb.get("kb_name", "")), - "doc_name": str(document.get("doc_name", "")), - "chunk_index": 0, - "content": content, - "score": score, - "char_count": len(content), - } - ) - results.sort(key=lambda item: float(item["score"]), reverse=True) - results = results[:top_m_final] - if not results: - return {"result": None} - return { - "result": { - "context_text": self._build_mock_context_text(results), - "results": results, - } - } - - async def _kb_document_upload( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - kb = self._kb_store.get(kb_id) - if not isinstance(kb, dict): - raise AstrBotError.invalid_input(f"Unknown knowledge base: {kb_id}") - raw_document = payload.get("document") - if not isinstance(raw_document, dict): - raise AstrBotError.invalid_input( - "kb.document.upload requires document object" - ) - - file_name = str(raw_document.get("file_name", "")).strip() - file_type = str(raw_document.get("file_type", "")).strip() - file_path = "" - content_text = "" - file_size = 0 - - text_value = raw_document.get("text") - url_value = raw_document.get("url") - file_token = str(raw_document.get("file_token", "")).strip() - - if isinstance(text_value, str) and text_value.strip(): - content_text = text_value - if not file_name: - file_name = "document.txt" - if not file_type: - file_type = "txt" - file_size = len(content_text.encode("utf-8")) - elif isinstance(url_value, str) and url_value.strip(): - url_text = url_value.strip() - content_text = f"Imported from {url_text}" - if not file_name: - file_name = ( - Path(url_text.split("?", maxsplit=1)[0]).name or "document.url" - ) - if not file_type: - suffix = Path(file_name).suffix.lstrip(".") - file_type = suffix or "url" - file_path = url_text - file_size = len(content_text.encode("utf-8")) - elif file_token: - file_path = self._file_token_store.pop(file_token, "") - if not file_path: - raise AstrBotError.invalid_input(f"Unknown file token: {file_token}") - path = Path(file_path) - if not path.exists(): - raise AstrBotError.invalid_input(f"File does not exist: {file_path}") - raw_bytes = path.read_bytes() - content_text = raw_bytes.decode("utf-8", errors="ignore") - if not file_name: - file_name = path.name - if not file_type: - file_type = path.suffix.lstrip(".") - if not file_type: - raise AstrBotError.invalid_input( - "kb.document.upload requires file_type when the file has no suffix" - ) - file_size = len(raw_bytes) - else: - raise AstrBotError.invalid_input( - "kb.document.upload requires file_token, url, or text" - ) - - chunk_size = self._optional_int(raw_document.get("chunk_size")) - if chunk_size is None or chunk_size <= 0: - chunk_size = self._optional_int(kb.get("chunk_size")) or 512 - chunk_count = max(1, math.ceil(max(len(content_text), 1) / chunk_size)) - doc_id = uuid.uuid4().hex - now = self._now_iso() - document = { - "doc_id": doc_id, - "kb_id": kb_id, - "doc_name": file_name, - "file_type": file_type, - "file_size": file_size, - "file_path": file_path, - "chunk_count": chunk_count, - "media_count": 0, - "created_at": now, - "updated_at": now, - } - self._kb_documents(kb_id)[doc_id] = document - self._kb_document_content_store[doc_id] = content_text - self._refresh_mock_kb_stats(kb_id) - return {"document": dict(document)} - - async def _kb_document_list( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - offset = max(self._optional_int(payload.get("offset")) or 0, 0) - limit = max(self._optional_int(payload.get("limit")) or 100, 0) - documents = list(self._kb_documents(kb_id).values()) - documents.sort(key=lambda item: str(item.get("created_at", ""))) - return { - "documents": [dict(item) for item in documents[offset : offset + limit]] - } - - async def _kb_document_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - doc_id = str(payload.get("doc_id", "")).strip() - document = self._kb_documents(kb_id).get(doc_id) - return {"document": dict(document) if isinstance(document, dict) else None} - - async def _kb_document_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - doc_id = str(payload.get("doc_id", "")).strip() - deleted = self._kb_documents(kb_id).pop(doc_id, None) is not None - if deleted: - self._kb_document_content_store.pop(doc_id, None) - self._refresh_mock_kb_stats(kb_id) - return {"deleted": deleted} - - async def _kb_document_refresh( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - kb_id = str(payload.get("kb_id", "")).strip() - doc_id = str(payload.get("doc_id", "")).strip() - document = self._kb_documents(kb_id).get(doc_id) - if not isinstance(document, dict): - return {"document": None} - kb = self._kb_store.get(kb_id, {}) - chunk_size = self._optional_int(kb.get("chunk_size")) or 512 - content_text = self._kb_document_content_store.get(doc_id, "") - document["chunk_count"] = max( - 1, math.ceil(max(len(content_text), 1) / chunk_size) - ) - document["updated_at"] = self._now_iso() - self._refresh_mock_kb_stats(kb_id) - return {"document": dict(document)} - - def _register_kb_capabilities(self) -> None: - self.register( - self._builtin_descriptor("kb.list", "列出知识库"), - call_handler=self._kb_list, - ) - self.register( - self._builtin_descriptor("kb.get", "获取知识库"), - call_handler=self._kb_get, - ) - self.register( - self._builtin_descriptor("kb.create", "创建知识库"), - call_handler=self._kb_create, - ) - self.register( - self._builtin_descriptor("kb.update", "更新知识库"), - call_handler=self._kb_update, - ) - self.register( - self._builtin_descriptor("kb.delete", "删除知识库"), - call_handler=self._kb_delete, - ) - self.register( - self._builtin_descriptor("kb.retrieve", "检索知识库"), - call_handler=self._kb_retrieve, - ) - self.register( - self._builtin_descriptor("kb.document.upload", "上传知识库文档"), - call_handler=self._kb_document_upload, - ) - self.register( - self._builtin_descriptor("kb.document.list", "列出知识库文档"), - call_handler=self._kb_document_list, - ) - self.register( - self._builtin_descriptor("kb.document.get", "获取知识库文档"), - call_handler=self._kb_document_get, - ) - self.register( - self._builtin_descriptor("kb.document.delete", "删除知识库文档"), - call_handler=self._kb_document_delete, - ) - self.register( - self._builtin_descriptor("kb.document.refresh", "刷新知识库文档"), - call_handler=self._kb_document_refresh, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/llm.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/llm.py deleted file mode 100644 index daf162112..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/llm.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from typing import Any - -from ..bridge_base import CapabilityRouterBridgeBase - - -class LLMCapabilityMixin(CapabilityRouterBridgeBase): - async def _llm_chat( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - prompt = str(payload.get("prompt", "")) - return {"text": f"Echo: {prompt}"} - - async def _llm_chat_raw( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - prompt = str(payload.get("prompt", "")) - text = f"Echo: {prompt}" - return { - "text": text, - "usage": { - "input_tokens": len(prompt), - "output_tokens": len(text), - }, - "finish_reason": "stop", - "tool_calls": [], - } - - async def _llm_stream( - self, - _request_id: str, - payload: dict[str, Any], - token, - ) -> AsyncIterator[dict[str, Any]]: - text = f"Echo: {str(payload.get('prompt', ''))}" - for char in text: - token.raise_if_cancelled() - await asyncio.sleep(0) - yield {"text": char} - - def _register_llm_capabilities(self) -> None: - self.register( - self._builtin_descriptor("llm.chat", "发送对话请求,返回文本"), - call_handler=self._llm_chat, - ) - self.register( - self._builtin_descriptor("llm.chat_raw", "发送对话请求,返回完整响应"), - call_handler=self._llm_chat_raw, - ) - self.register( - self._builtin_descriptor( - "llm.stream_chat", - "流式对话", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._llm_stream, - finalize=lambda chunks: { - "text": "".join(item.get("text", "") for item in chunks) - }, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/mcp.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/mcp.py deleted file mode 100644 index 33582f5b4..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/mcp.py +++ /dev/null @@ -1,527 +0,0 @@ -from __future__ import annotations - -import asyncio -import uuid -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -def _mock_tools_from_config(name: str, config: dict[str, Any]) -> list[str]: - configured = config.get("mock_tools") - if isinstance(configured, list): - tools = [str(item) for item in configured if str(item).strip()] - if tools: - return tools - return [f"{name}_tool"] - - -def _mock_server_record( - *, - name: str, - scope: str, - active: bool, - running: bool, - config: dict[str, Any], - tools: list[str], - errlogs: list[str] | None = None, - last_error: str | None = None, -) -> dict[str, Any]: - return { - "name": name, - "scope": scope, - "active": bool(active), - "running": bool(running), - "config": dict(config), - "tools": list(tools), - "errlogs": list(errlogs or []), - "last_error": last_error, - } - - -class McpCapabilityMixin(CapabilityRouterBridgeBase): - def _plugin_local_mcp_servers(self, plugin_id: str) -> dict[str, dict[str, Any]]: - plugin = self._plugins.get(plugin_id) - if plugin is None: - raise AstrBotError.invalid_input(f"Unknown plugin: {plugin_id}") - return plugin.local_mcp_servers - - @staticmethod - def _require_server_name(payload: dict[str, Any], capability_name: str) -> str: - name = str(payload.get("name", "")).strip() - if not name: - raise AstrBotError.invalid_input(f"{capability_name} requires name") - return name - - @staticmethod - def _normalized_timeout(payload: dict[str, Any], default: float = 30.0) -> float: - raw_value = payload.get("timeout", default) - try: - timeout = float(raw_value) - except (TypeError, ValueError) as exc: - raise AstrBotError.invalid_input("timeout must be numeric") from exc - if timeout <= 0: - raise AstrBotError.invalid_input("timeout must be greater than 0") - return timeout - - def _mock_connect_outcome( - self, - *, - name: str, - config: dict[str, Any], - scope: str, - ) -> dict[str, Any]: - if bool(config.get("mock_fail", False)): - last_error = str(config.get("mock_error") or f"{name} failed") - return _mock_server_record( - name=name, - scope=scope, - active=bool(config.get("active", True)), - running=False, - config=config, - tools=[], - errlogs=[last_error], - last_error=last_error, - ) - return _mock_server_record( - name=name, - scope=scope, - active=bool(config.get("active", True)), - running=True, - config=config, - tools=_mock_tools_from_config(name, config), - errlogs=[], - last_error=None, - ) - - async def _mcp_local_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.local.get") - name = self._require_server_name(payload, "mcp.local.get") - return { - "server": self._plugin_local_mcp_servers(plugin_id).get(name), - } - - async def _mcp_local_list( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.local.list") - servers = sorted( - self._plugin_local_mcp_servers(plugin_id).values(), - key=lambda item: str(item.get("name", "")), - ) - return {"servers": [dict(item) for item in servers]} - - async def _mcp_local_enable( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.local.enable") - name = self._require_server_name(payload, "mcp.local.enable") - servers = self._plugin_local_mcp_servers(plugin_id) - server = servers.get(name) - if server is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - if bool(server.get("active", False)) and bool(server.get("running", False)): - return {"server": dict(server)} - updated = self._mock_connect_outcome( - name=name, - config=dict(server.get("config", {})), - scope="local", - ) - updated["active"] = True - servers[name] = updated - return {"server": dict(updated)} - - async def _mcp_local_disable( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.local.disable") - name = self._require_server_name(payload, "mcp.local.disable") - servers = self._plugin_local_mcp_servers(plugin_id) - server = servers.get(name) - if server is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - if not bool(server.get("active", False)) and not bool( - server.get("running", False) - ): - return {"server": dict(server)} - updated = dict(server) - updated["active"] = False - updated["running"] = False - servers[name] = updated - return {"server": updated} - - async def _mcp_local_wait_until_ready( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.local.wait_until_ready") - name = self._require_server_name(payload, "mcp.local.wait_until_ready") - timeout = self._normalized_timeout(payload) - server = self._plugin_local_mcp_servers(plugin_id).get(name) - if server is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - if bool(server.get("running", False)): - return {"server": dict(server)} - delay = float(server.get("config", {}).get("mock_connect_delay", 0.0) or 0.0) - if delay > timeout: - raise TimeoutError( - f"Local MCP server '{name}' did not become ready in time" - ) - if delay > 0: - await asyncio.sleep(delay) - if bool(server.get("active", False)) and not bool( - server.get("config", {}).get("mock_fail", False) - ): - refreshed = self._mock_connect_outcome( - name=name, - config=dict(server.get("config", {})), - scope="local", - ) - refreshed["active"] = bool(server.get("active", False)) - self._plugin_local_mcp_servers(plugin_id)[name] = refreshed - refreshed = self._plugin_local_mcp_servers(plugin_id).get(name) - if refreshed is None or not bool(refreshed.get("running", False)): - raise TimeoutError( - f"Local MCP server '{name}' did not become ready in time" - ) - return {"server": dict(refreshed)} - - async def _mcp_session_open( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.session.open") - name = self._require_server_name(payload, "mcp.session.open") - config = payload.get("config") - if not isinstance(config, dict): - raise AstrBotError.invalid_input("mcp.session.open requires config object") - timeout = self._normalized_timeout(payload) - delay = float(config.get("mock_connect_delay", 0.0) or 0.0) - if bool(config.get("mock_fail", False)) or delay > timeout: - raise TimeoutError(f"MCP session '{name}' failed to connect in time") - if delay > 0: - await asyncio.sleep(delay) - session_id = f"{plugin_id}:{uuid.uuid4().hex}" - tools = _mock_tools_from_config(name, dict(config)) - self._mcp_session_store[session_id] = { - "plugin_id": plugin_id, - "name": name, - "config": dict(config), - "tools": tools, - "tool_results": dict(config.get("mock_tool_results", {})) - if isinstance(config.get("mock_tool_results"), dict) - else {}, - } - return {"session_id": session_id, "tools": list(tools)} - - async def _mcp_session_list_tools( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session_id = str(payload.get("session_id", "")).strip() - session = self._mcp_session_store.get(session_id) - if session is None: - raise AstrBotError.invalid_input("Unknown MCP session") - return {"tools": list(session.get("tools", []))} - - async def _mcp_session_call_tool( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session_id = str(payload.get("session_id", "")).strip() - session = self._mcp_session_store.get(session_id) - if session is None: - raise AstrBotError.invalid_input("Unknown MCP session") - tool_name = str(payload.get("tool_name", "")).strip() - if not tool_name: - raise AstrBotError.invalid_input("mcp.session.call_tool requires tool_name") - args = payload.get("args") - if not isinstance(args, dict): - raise AstrBotError.invalid_input( - "mcp.session.call_tool requires args object" - ) - tool_results = session.get("tool_results", {}) - if isinstance(tool_results, dict) and tool_name in tool_results: - result = tool_results[tool_name] - return { - "result": dict(result) - if isinstance(result, dict) - else {"value": result} - } - return { - "result": { - "tool_name": tool_name, - "arguments": dict(args), - "content": f"mock:{session['name']}:{tool_name}", - } - } - - async def _mcp_session_close( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session_id = str(payload.get("session_id", "")).strip() - self._mcp_session_store.pop(session_id, None) - return {} - - def _require_global_mcp_risk_ack( - self, - plugin_id: str, - capability_name: str, - ) -> None: - plugin = self._plugins.get(plugin_id) - metadata = plugin.metadata if plugin is not None else {} - if bool(metadata.get("acknowledge_global_mcp_risk", False)): - return - raise PermissionError( - f"{capability_name} requires @acknowledge_global_mcp_risk" - ) - - def _audit_global_mcp_mutation( - self, - *, - plugin_id: str, - action: str, - server_name: str, - request_id: str, - ) -> None: - self._mcp_audit_logs.append( - { - "plugin_id": plugin_id, - "action": action, - "server_name": server_name, - "request_id": request_id, - } - ) - - async def _mcp_global_register( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.register") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.register") - name = self._require_server_name(payload, "mcp.global.register") - config = payload.get("config") - if not isinstance(config, dict): - raise AstrBotError.invalid_input( - "mcp.global.register requires config object" - ) - if name in self._mcp_global_servers: - raise AstrBotError.invalid_input( - f"Global MCP server already exists: {name}" - ) - record = self._mock_connect_outcome( - name=name, - config=dict(config), - scope="global", - ) - self._mcp_global_servers[name] = record - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="register", - server_name=name, - request_id=request_id, - ) - return {"server": dict(record)} - - async def _mcp_global_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.get") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.get") - name = self._require_server_name(payload, "mcp.global.get") - return {"server": self._mcp_global_servers.get(name)} - - async def _mcp_global_list( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.list") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.list") - servers = sorted( - self._mcp_global_servers.values(), - key=lambda item: str(item.get("name", "")), - ) - return {"servers": [dict(item) for item in servers]} - - async def _mcp_global_enable( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.enable") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.enable") - name = self._require_server_name(payload, "mcp.global.enable") - record = self._mcp_global_servers.get(name) - if record is None: - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - updated = self._mock_connect_outcome( - name=name, - config=dict(record.get("config", {})), - scope="global", - ) - updated["active"] = True - self._mcp_global_servers[name] = updated - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="enable", - server_name=name, - request_id=request_id, - ) - return {"server": dict(updated)} - - async def _mcp_global_disable( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.disable") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.disable") - name = self._require_server_name(payload, "mcp.global.disable") - record = self._mcp_global_servers.get(name) - if record is None: - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - updated = dict(record) - updated["active"] = False - updated["running"] = False - self._mcp_global_servers[name] = updated - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="disable", - server_name=name, - request_id=request_id, - ) - return {"server": dict(updated)} - - async def _mcp_global_unregister( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("mcp.global.unregister") - self._require_global_mcp_risk_ack(plugin_id, "mcp.global.unregister") - name = self._require_server_name(payload, "mcp.global.unregister") - record = self._mcp_global_servers.pop(name, None) - if record is None: - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="unregister", - server_name=name, - request_id=request_id, - ) - return {"server": dict(record)} - - async def _internal_mcp_local_execute( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = str(payload.get("plugin_id", "")).strip() - server_name = str(payload.get("server_name", "")).strip() - tool_name = str(payload.get("tool_name", "")).strip() - tool_args = payload.get("tool_args") - if not plugin_id or not server_name or not tool_name: - raise AstrBotError.invalid_input( - "internal.mcp.local.execute requires plugin_id, server_name, and tool_name" - ) - if not isinstance(tool_args, dict): - raise AstrBotError.invalid_input( - "internal.mcp.local.execute requires tool_args object" - ) - plugin = self._plugins.get(plugin_id) - server = ( - plugin.local_mcp_servers.get(server_name) if plugin is not None else None - ) - if server is None or not bool(server.get("running", False)): - return { - "content": f"Local MCP server unavailable: {server_name}", - "success": False, - } - if tool_name not in server.get("tools", []): - return { - "content": f"Local MCP tool not found: {server_name}.{tool_name}", - "success": False, - } - return { - "content": f"mock:{server_name}:{tool_name}:{tool_args}", - "success": True, - } - - def _register_mcp_capabilities(self) -> None: - self.register( - self._builtin_descriptor("mcp.local.get", "Get local MCP server"), - call_handler=self._mcp_local_get, - ) - self.register( - self._builtin_descriptor("mcp.local.list", "List local MCP servers"), - call_handler=self._mcp_local_list, - ) - self.register( - self._builtin_descriptor("mcp.local.enable", "Enable local MCP server"), - call_handler=self._mcp_local_enable, - ) - self.register( - self._builtin_descriptor("mcp.local.disable", "Disable local MCP server"), - call_handler=self._mcp_local_disable, - ) - self.register( - self._builtin_descriptor( - "mcp.local.wait_until_ready", - "Wait until local MCP server is ready", - ), - call_handler=self._mcp_local_wait_until_ready, - ) - self.register( - self._builtin_descriptor("mcp.session.open", "Open temporary MCP session"), - call_handler=self._mcp_session_open, - ) - self.register( - self._builtin_descriptor( - "mcp.session.list_tools", - "List tools in temporary MCP session", - ), - call_handler=self._mcp_session_list_tools, - ) - self.register( - self._builtin_descriptor( - "mcp.session.call_tool", - "Call tool in temporary MCP session", - ), - call_handler=self._mcp_session_call_tool, - ) - self.register( - self._builtin_descriptor( - "mcp.session.close", "Close temporary MCP session" - ), - call_handler=self._mcp_session_close, - ) - self.register( - self._builtin_descriptor( - "mcp.global.register", - "Register global MCP server", - ), - call_handler=self._mcp_global_register, - ) - self.register( - self._builtin_descriptor("mcp.global.get", "Get global MCP server"), - call_handler=self._mcp_global_get, - ) - self.register( - self._builtin_descriptor("mcp.global.list", "List global MCP servers"), - call_handler=self._mcp_global_list, - ) - self.register( - self._builtin_descriptor("mcp.global.enable", "Enable global MCP server"), - call_handler=self._mcp_global_enable, - ) - self.register( - self._builtin_descriptor( - "mcp.global.disable", - "Disable global MCP server", - ), - call_handler=self._mcp_global_disable, - ) - self.register( - self._builtin_descriptor( - "mcp.global.unregister", - "Unregister global MCP server", - ), - call_handler=self._mcp_global_unregister, - ) - self.register( - self._builtin_descriptor( - "internal.mcp.local.execute", - "Execute local MCP tool", - ), - call_handler=self._internal_mcp_local_execute, - exposed=False, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/memory.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/memory.py deleted file mode 100644 index 2ea8875eb..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/memory.py +++ /dev/null @@ -1,582 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any - -from ...._internal.invocation_context import current_caller_plugin_id -from ...._internal.memory_utils import ( - cosine_similarity, - extract_memory_text, - is_ttl_memory_entry, - memory_expiration_from_ttl, - memory_index_entry, - memory_keyword_score, - memory_value_for_search, -) -from ...._memory_backend import PluginMemoryBackend -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class MemoryCapabilityMixin(CapabilityRouterBridgeBase): - def _memory_plugin_id(self) -> str: - plugin_id = current_caller_plugin_id() - return self._validated_plugin_id( - str(plugin_id).strip() or "__anonymous__", - capability_name="memory.*", - ) - - def _memory_backend_for_plugin(self, plugin_id: str) -> PluginMemoryBackend: - backend = self._memory_backends.get(plugin_id) - if backend is None: - backend = PluginMemoryBackend( - self._plugin_data_dir(plugin_id, capability_name="memory.*") - ) - self._memory_backends[plugin_id] = backend - return backend - - @staticmethod - def _is_ttl_memory_entry(value: Any) -> bool: - """判断存储值是否使用了 TTL 包装结构。 - - Args: - value: 待检查的存储值。 - - Returns: - bool: 如果值包含 ``value`` 和 ``ttl_seconds`` 字段则返回 ``True``。 - """ - return is_ttl_memory_entry(value) - - @classmethod - def _memory_value_for_search(cls, stored: Any) -> dict[str, Any] | None: - """提取用于检索的原始 memory payload。 - - Args: - stored: memory_store 中保存的原始值。 - - Returns: - dict[str, Any] | None: 解开 TTL 包装后的字典,无法解析时返回 ``None``。 - """ - return memory_value_for_search(stored) - - @classmethod - def _extract_memory_text(cls, stored: Any) -> str: - """提取用于检索索引的首选文本。 - - Args: - stored: memory_store 中保存的原始值。 - - Returns: - str: 优先使用 ``embedding_text`` / ``content`` 等字段,兜底为 JSON 文本。 - """ - return extract_memory_text(stored) - - @staticmethod - def _memory_expiration_from_ttl(ttl_seconds: Any) -> datetime | None: - """将 TTL 秒数转换为 UTC 过期时间。 - - Args: - ttl_seconds: TTL 秒数。 - - Returns: - datetime | None: 绝对过期时间;当输入无效时返回 ``None``。 - """ - return memory_expiration_from_ttl(ttl_seconds) - - @staticmethod - def _memory_keyword_score(query: str, key: str, text: str) -> float: - """计算关键词匹配分数。 - - Args: - query: 查询文本。 - key: memory 条目的键。 - text: 已索引的检索文本。 - - Returns: - float: 基于键名和文本命中的粗粒度关键词分数。 - """ - return memory_keyword_score(query, key, text) - - @staticmethod - def _cosine_similarity(left: list[float], right: list[float]) -> float: - """计算两个向量之间的余弦相似度。 - - Args: - left: 左侧向量。 - right: 右侧向量。 - - Returns: - float: 余弦相似度;输入不合法时返回 ``0.0``。 - """ - return cosine_similarity(left, right) - - def _resolve_memory_embedding_provider_id( - self, - provider_id: Any, - *, - required: bool, - ) -> str | None: - """解析 memory.search 要使用的 embedding provider。 - - Args: - provider_id: 调用方显式传入的 provider 标识。 - required: 当前检索模式是否强制要求 embedding provider。 - - Returns: - str | None: 最终选中的 provider 标识;在非强制场景下允许返回 ``None``。 - """ - normalized = str(provider_id).strip() if provider_id is not None else "" - if normalized: - self._provider_entry( - {"provider_id": normalized}, - "memory.search", - "embedding", - ) - return normalized - active_id = self._active_provider_ids.get("embedding") - if active_id is not None: - normalized_active = str(active_id).strip() - if normalized_active: - self._provider_entry( - {"provider_id": normalized_active}, - "memory.search", - "embedding", - ) - return normalized_active - if required: - raise AstrBotError.invalid_input( - "memory.search requires an embedding provider", - ) - return None - - @staticmethod - def _memory_index_entry(entry: Any, *, text: str) -> dict[str, Any]: - """将原始索引项规范化为内部统一结构。 - - Args: - entry: 当前索引表中的原始项。 - text: 当前条目的索引文本。 - - Returns: - dict[str, Any]: 统一后的索引项,包含 ``text``、``embedding``、``provider_id``。 - """ - return memory_index_entry(entry, text=text) - - def _clear_memory_sidecars(self, key: str) -> None: - """清理指定 memory 键对应的所有 sidecar 状态。 - - Args: - key: memory 条目的键。 - - Returns: - None - """ - self._memory_index.pop(key, None) - self._memory_expires_at.pop(key, None) - self._memory_dirty_keys.discard(key) - - def _delete_memory_entry(self, key: str) -> bool: - """删除 memory 条目并同步清理 sidecar 状态。 - - Args: - key: memory 条目的键。 - - Returns: - bool: 条目存在并删除成功时返回 ``True``。 - """ - deleted = self.memory_store.pop(key, None) is not None - self._clear_memory_sidecars(key) - return deleted - - def _upsert_memory_sidecars( - self, - key: str, - stored: dict[str, Any], - *, - expires_at: datetime | None = None, - ) -> None: - """创建或更新单条 memory 的 sidecar 索引状态。 - - Args: - key: memory 条目的键。 - stored: 需要建立索引的原始存储值。 - expires_at: 可选的绝对过期时间。 - - Returns: - None - """ - self._memory_index[key] = { - "text": self._extract_memory_text(stored), - "embedding": None, - "provider_id": None, - } - if expires_at is None: - self._memory_expires_at.pop(key, None) - else: - self._memory_expires_at[key] = expires_at - self._memory_dirty_keys.add(key) - - def _ensure_memory_sidecars(self, key: str, stored: Any) -> None: - """确保 sidecar 状态与当前存储值保持一致。 - - Args: - key: memory 条目的键。 - stored: memory_store 中的当前存储值。 - - Returns: - None - """ - if not isinstance(stored, dict): - return - text = self._extract_memory_text(stored) - existed = key in self._memory_index - entry = self._memory_index_entry(self._memory_index.get(key), text=text) - if entry["text"] != text: - entry["text"] = text - entry["embedding"] = None - entry["provider_id"] = None - self._memory_dirty_keys.add(key) - self._memory_index[key] = entry - if not existed: - self._memory_dirty_keys.add(key) - - def _is_memory_expired(self, key: str) -> bool: - """判断 memory 条目是否已过期。 - - Args: - key: memory 条目的键。 - - Returns: - bool: 如果当前时间已超过记录的过期时间则返回 ``True``。 - """ - expires_at = self._memory_expires_at.get(key) - return expires_at is not None and expires_at <= datetime.now(timezone.utc) - - def _purge_expired_memory_entry(self, key: str) -> bool: - """在单条 memory 已过期时立即清理它。 - - Args: - key: memory 条目的键。 - - Returns: - bool: 如果条目已过期并被成功清理则返回 ``True``。 - """ - if not self._is_memory_expired(key): - return False - self._delete_memory_entry(key) - return True - - def _purge_expired_memory_entries(self) -> None: - """批量清理所有已跟踪的过期 TTL 条目。 - - Returns: - None - """ - for key in list(self._memory_expires_at): - self._purge_expired_memory_entry(key) - - async def _embedding_for_text( - self, - *, - provider_id: str, - text: str, - ) -> list[float]: - """通过 embedding capability 获取单条文本向量。 - - Args: - provider_id: 使用的 embedding provider 标识。 - text: 待向量化的文本。 - - Returns: - list[float]: provider 返回的向量;异常场景下返回空列表。 - """ - output = await self._provider_embedding_get_embedding( - "", - {"provider_id": provider_id, "text": text}, - None, - ) - embedding = output.get("embedding") - if not isinstance(embedding, list): - return [] - return [float(item) for item in embedding] - - async def _embeddings_for_texts( - self, - *, - provider_id: str, - texts: list[str], - ) -> list[list[float]]: - """批量获取多条文本的 embedding 向量。 - - Args: - provider_id: 使用的 embedding provider 标识。 - texts: 待向量化的文本列表。 - - Returns: - list[list[float]]: 与输入顺序对应的向量列表。 - """ - if not texts: - return [] - output = await self._provider_embedding_get_embeddings( - "", - {"provider_id": provider_id, "texts": texts}, - None, - ) - embeddings = output.get("embeddings") - if not isinstance(embeddings, list): - return [] - return [ - [float(value) for value in item] - for item in embeddings - if isinstance(item, list) - ] - - async def _refresh_memory_embeddings(self, *, provider_id: str) -> None: - """刷新当前 provider 下脏或过期的 memory 向量索引。 - - Args: - provider_id: 当前使用的 embedding provider 标识。 - - Returns: - None - """ - keys_to_refresh: list[str] = [] - texts_to_refresh: list[str] = [] - for key, stored in self.memory_store.items(): - self._ensure_memory_sidecars(key, stored) - entry = self._memory_index_entry( - self._memory_index.get(key), - text=self._extract_memory_text(stored), - ) - should_refresh = ( - key in self._memory_dirty_keys - or entry["embedding"] is None - or entry["provider_id"] != provider_id - ) - self._memory_index[key] = entry - if should_refresh: - keys_to_refresh.append(key) - texts_to_refresh.append(str(entry["text"])) - # 分批请求,避免单次 payload 过大导致 OOM 或 413 - _BATCH_SIZE = 64 - embeddings: list[list[float]] = [] - for batch_start in range(0, len(texts_to_refresh), _BATCH_SIZE): - batch = texts_to_refresh[batch_start : batch_start + _BATCH_SIZE] - embeddings.extend( - await self._embeddings_for_texts( - provider_id=provider_id, - texts=batch, - ) - ) - for index, key in enumerate(keys_to_refresh): - entry = self._memory_index_entry( - self._memory_index.get(key), - text=str(texts_to_refresh[index]), - ) - entry["embedding"] = embeddings[index] if index < len(embeddings) else [] - entry["provider_id"] = provider_id - self._memory_index[key] = entry - self._memory_dirty_keys.discard(key) - - async def _memory_search( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - query = str(payload.get("query", "")) - mode = str(payload.get("mode", "auto")).strip().lower() or "auto" - limit = self._optional_int(payload.get("limit")) - raw_min_score = payload.get("min_score") - min_score = float(raw_min_score) if raw_min_score is not None else None - namespace = payload.get("namespace") - include_descendants = bool(payload.get("include_descendants", True)) - provider_id = self._resolve_memory_embedding_provider_id( - payload.get("provider_id"), - required=mode in {"vector", "hybrid"}, - ) - effective_mode = mode - if effective_mode == "auto": - effective_mode = "hybrid" if provider_id is not None else "keyword" - backend = self._memory_backend_for_plugin(plugin_id) - items = await backend.search( - query, - namespace=str(namespace) if namespace is not None else None, - include_descendants=include_descendants, - mode=effective_mode, - limit=limit, - min_score=min_score, - provider_id=provider_id, - embed_one=( - ( - lambda text: self._embedding_for_text( - provider_id=provider_id, text=text - ) - ) - if provider_id is not None and effective_mode in {"vector", "hybrid"} - else None - ), - embed_many=( - ( - lambda texts: self._embeddings_for_texts( - provider_id=provider_id, - texts=texts, - ) - ) - if provider_id is not None and effective_mode in {"vector", "hybrid"} - else None - ), - ) - return {"items": items} - - async def _memory_save( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - key = str(payload.get("key", "")) - value = payload.get("value") - if not isinstance(value, dict): - raise AstrBotError.invalid_input("memory.save 的 value 必须是 object") - await self._memory_backend_for_plugin(plugin_id).save( - key, - value, - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - key = str(payload.get("key", "")) - value = await self._memory_backend_for_plugin(plugin_id).get( - key, - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"value": value} - - async def _memory_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - await self._memory_backend_for_plugin(plugin_id).delete( - str(payload.get("key", "")), - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_save_with_ttl( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - key = str(payload.get("key", "")) - value = payload.get("value") - ttl_seconds = payload.get("ttl_seconds", 0) - if not isinstance(value, dict): - raise AstrBotError.invalid_input( - "memory.save_with_ttl 的 value 必须是 object" - ) - await self._memory_backend_for_plugin(plugin_id).save_with_ttl( - key, - value, - int(ttl_seconds), - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_get_many( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - keys_payload = payload.get("keys") - if not isinstance(keys_payload, (list, tuple)): - raise AstrBotError.invalid_input("memory.get_many 的 keys 必须是数组") - items = await self._memory_backend_for_plugin(plugin_id).get_many( - [str(item) for item in keys_payload], - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"items": items} - - async def _memory_delete_many( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - keys_payload = payload.get("keys") - if not isinstance(keys_payload, (list, tuple)): - raise AstrBotError.invalid_input("memory.delete_many 的 keys 必须是数组") - deleted_count = await self._memory_backend_for_plugin(plugin_id).delete_many( - [str(item) for item in keys_payload], - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"deleted_count": deleted_count} - - async def _memory_stats( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._memory_plugin_id() - stats = await self._memory_backend_for_plugin(plugin_id).stats( - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - include_descendants=bool(payload.get("include_descendants", True)), - ) - stats["plugin_id"] = plugin_id - return stats - - def _register_memory_capabilities(self) -> None: - self.register( - self._builtin_descriptor("memory.search", "搜索记忆"), - call_handler=self._memory_search, - ) - self.register( - self._builtin_descriptor("memory.save", "保存记忆"), - call_handler=self._memory_save, - ) - self.register( - self._builtin_descriptor("memory.get", "读取单条记忆"), - call_handler=self._memory_get, - ) - self.register( - self._builtin_descriptor("memory.delete", "删除记忆"), - call_handler=self._memory_delete, - ) - self.register( - self._builtin_descriptor("memory.save_with_ttl", "保存带过期时间的记忆"), - call_handler=self._memory_save_with_ttl, - ) - self.register( - self._builtin_descriptor("memory.get_many", "批量获取记忆"), - call_handler=self._memory_get_many, - ) - self.register( - self._builtin_descriptor("memory.delete_many", "批量删除记忆"), - call_handler=self._memory_delete_many, - ) - self.register( - self._builtin_descriptor("memory.stats", "获取记忆统计信息"), - call_handler=self._memory_stats, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/message_history.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/message_history.py deleted file mode 100644 index c1562ef5a..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/message_history.py +++ /dev/null @@ -1,322 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from ....errors import AstrBotError -from ....message.session import MessageSession -from ..bridge_base import CapabilityRouterBridgeBase - - -def _session_payload(session: MessageSession) -> dict[str, str]: - return { - "platform_id": str(session.platform_id), - "message_type": str(session.message_type), - "session_id": str(session.session_id), - } - - -class MessageHistoryCapabilityMixin(CapabilityRouterBridgeBase): - @staticmethod - def _typed_session_from_payload(payload: Any) -> MessageSession: - if not isinstance(payload, dict): - raise AstrBotError.invalid_input( - "message_history capabilities require a session object" - ) - platform_id = str(payload.get("platform_id", "")).strip() - message_type = str(payload.get("message_type", "")).strip() - session_id = str(payload.get("session_id", "")).strip() - if not platform_id or not message_type or not session_id: - raise AstrBotError.invalid_input( - "message_history session requires platform_id, message_type, and session_id" - ) - return MessageSession( - platform_id=platform_id, - message_type=message_type, - session_id=session_id, - ) - - @staticmethod - def _typed_key(session: MessageSession) -> str: - return ( - f"{str(session.platform_id)}:{str(session.message_type).lower()}:" - f"{str(session.session_id)}" - ) - - def _message_history_records(self, session: MessageSession) -> list[dict[str, Any]]: - key = self._typed_key(session) - records = self._message_history_store.get(key) - if records is None: - records = [] - self._message_history_store[key] = records - return records - - def _next_message_history_id(self) -> int: - next_id = int(self._message_history_next_id) - self._message_history_next_id += 1 - return next_id - - def _create_message_history_record( - self, - *, - session: MessageSession, - sender_payload: dict[str, Any], - parts_payload: list[dict[str, Any]], - metadata: dict[str, Any], - idempotency_key: str | None, - ) -> dict[str, Any]: - now = self._now_iso() - return { - "id": self._next_message_history_id(), - "session": _session_payload(session), - "sender": { - "sender_id": ( - str(sender_payload.get("sender_id")) - if sender_payload.get("sender_id") is not None - else None - ), - "sender_name": ( - str(sender_payload.get("sender_name")) - if sender_payload.get("sender_name") is not None - else None - ), - }, - "parts": [dict(item) for item in parts_payload if isinstance(item, dict)], - "metadata": dict(metadata), - "created_at": now, - "updated_at": now, - "idempotency_key": idempotency_key, - } - - @staticmethod - def _serialize_record(record: dict[str, Any]) -> dict[str, Any]: - return { - "id": int(record.get("id", 0) or 0), - "session": ( - dict(record.get("session")) - if isinstance(record.get("session"), dict) - else {} - ), - "sender": ( - dict(record.get("sender")) - if isinstance(record.get("sender"), dict) - else {} - ), - "parts": ( - [ - dict(item) - for item in record.get("parts", []) - if isinstance(item, dict) - ] - if isinstance(record.get("parts"), list) - else [] - ), - "metadata": ( - dict(record.get("metadata")) - if isinstance(record.get("metadata"), dict) - else {} - ), - "created_at": record.get("created_at"), - "updated_at": record.get("updated_at"), - "idempotency_key": ( - str(record.get("idempotency_key")) - if record.get("idempotency_key") is not None - else None - ), - } - - @staticmethod - def _parse_boundary(raw_value: Any, field_name: str) -> datetime: - text = str(raw_value or "").strip() - if not text: - raise AstrBotError.invalid_input( - f"message_history.{field_name} requires {field_name}" - ) - try: - return datetime.fromisoformat(text) - except ValueError as exc: - raise AstrBotError.invalid_input( - f"message_history.{field_name} requires an ISO datetime string" - ) from exc - - async def _message_history_list( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - raw_limit = self._optional_int(payload.get("limit")) - limit = 50 if raw_limit is None else raw_limit - if limit < 1: - raise AstrBotError.invalid_input("message_history.list requires limit >= 1") - cursor = payload.get("cursor") - cursor_id = int(str(cursor)) if cursor not in (None, "") else None - records = list(reversed(self._message_history_records(session))) - total = len(records) - if cursor_id is not None: - records = [ - record for record in records if int(record.get("id", 0)) < cursor_id - ] - page_records = records[:limit] - next_cursor = ( - str(page_records[-1]["id"]) - if len(records) > limit and page_records - else None - ) - return { - "page": { - "records": [self._serialize_record(record) for record in page_records], - "next_cursor": next_cursor, - "total": total, - } - } - - async def _message_history_get_by_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - record_id = self._optional_int(payload.get("record_id")) - if record_id is None or record_id < 1: - raise AstrBotError.invalid_input( - "message_history.get_by_id requires record_id >= 1" - ) - record = next( - ( - item - for item in self._message_history_records(session) - if int(item.get("id", 0) or 0) == record_id - ), - None, - ) - return { - "record": self._serialize_record(record) if record is not None else None - } - - async def _message_history_append( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - sender_payload = payload.get("sender") - if not isinstance(sender_payload, dict): - raise AstrBotError.invalid_input( - "message_history.append requires sender object" - ) - parts_payload = payload.get("parts") - if not isinstance(parts_payload, list) or any( - not isinstance(item, dict) for item in parts_payload - ): - raise AstrBotError.invalid_input( - "message_history.append requires parts array" - ) - metadata = payload.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - raise AstrBotError.invalid_input( - "message_history.append requires metadata object when provided" - ) - idempotency_key = ( - str(payload.get("idempotency_key")) - if payload.get("idempotency_key") is not None - else None - ) - records = self._message_history_records(session) - if idempotency_key: - existing = next( - ( - record - for record in records - if str(record.get("idempotency_key") or "") == idempotency_key - ), - None, - ) - if existing is not None: - return {"record": self._serialize_record(existing)} - record = self._create_message_history_record( - session=session, - sender_payload=sender_payload, - parts_payload=parts_payload, - metadata=dict(metadata or {}), - idempotency_key=idempotency_key, - ) - records.append(record) - return {"record": self._serialize_record(record)} - - async def _message_history_delete_before( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - before = self._parse_boundary(payload.get("before"), "delete_before") - records = self._message_history_records(session) - retained: list[dict[str, Any]] = [] - deleted_count = 0 - for record in records: - created_at = datetime.fromisoformat(str(record.get("created_at"))) - if created_at < before: - deleted_count += 1 - continue - retained.append(record) - self._message_history_store[self._typed_key(session)] = retained - return {"deleted_count": deleted_count} - - async def _message_history_delete_after( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - after = self._parse_boundary(payload.get("after"), "delete_after") - records = self._message_history_records(session) - retained: list[dict[str, Any]] = [] - deleted_count = 0 - for record in records: - created_at = datetime.fromisoformat(str(record.get("created_at"))) - if created_at > after: - deleted_count += 1 - continue - retained.append(record) - self._message_history_store[self._typed_key(session)] = retained - return {"deleted_count": deleted_count} - - async def _message_history_delete_all( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = self._typed_session_from_payload(payload.get("session")) - key = self._typed_key(session) - deleted_count = len(self._message_history_store.get(key, [])) - self._message_history_store[key] = [] - return {"deleted_count": deleted_count} - - def _register_message_history_capabilities(self) -> None: - self.register( - self._builtin_descriptor("message_history.list", "List message history"), - call_handler=self._message_history_list, - ) - self.register( - self._builtin_descriptor( - "message_history.get_by_id", - "Get message history by id", - ), - call_handler=self._message_history_get_by_id, - ) - self.register( - self._builtin_descriptor( - "message_history.append", "Append message history" - ), - call_handler=self._message_history_append, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_before", - "Delete message history before timestamp", - ), - call_handler=self._message_history_delete_before, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_after", - "Delete message history after timestamp", - ), - call_handler=self._message_history_delete_after, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_all", - "Delete all message history in session", - ), - call_handler=self._message_history_delete_all, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/metadata.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/metadata.py deleted file mode 100644 index 787f63369..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/metadata.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ..bridge_base import CapabilityRouterBridgeBase - - -class MetadataCapabilityMixin(CapabilityRouterBridgeBase): - async def _metadata_get_plugin( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - name = str(payload.get("name", "")).strip() - plugin = self._plugins.get(name) - if plugin is None: - return {"plugin": None} - return {"plugin": dict(plugin.metadata)} - - async def _metadata_list_plugins( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugins = [ - dict(self._plugins[name].metadata) for name in sorted(self._plugins.keys()) - ] - return {"plugins": plugins} - - async def _metadata_get_plugin_config( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - name = str(payload.get("name", "")).strip() - caller_plugin_id = self._require_caller_plugin_id("metadata.get_plugin_config") - if name != caller_plugin_id: - return {"config": None} - plugin = self._plugins.get(name) - if plugin is None: - return {"config": None} - return {"config": dict(plugin.config)} - - async def _metadata_save_plugin_config( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - caller_plugin_id = self._require_caller_plugin_id("metadata.save_plugin_config") - plugin = self._plugins.get(caller_plugin_id) - if plugin is None: - return {"config": None} - config = payload.get("config") - if not isinstance(config, dict): - return {"config": dict(plugin.config)} - plugin.config = dict(config) - return {"config": dict(plugin.config)} - - def _register_metadata_capabilities(self) -> None: - self.register( - self._builtin_descriptor("metadata.get_plugin", "获取单个插件元数据"), - call_handler=self._metadata_get_plugin, - ) - self.register( - self._builtin_descriptor("metadata.list_plugins", "列出插件元数据"), - call_handler=self._metadata_list_plugins, - ) - self.register( - self._builtin_descriptor( - "metadata.get_plugin_config", - "获取插件配置", - ), - call_handler=self._metadata_get_plugin_config, - ) - self.register( - self._builtin_descriptor( - "metadata.save_plugin_config", - "保存当前插件配置", - ), - call_handler=self._metadata_save_plugin_config, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/persona.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/persona.py deleted file mode 100644 index 6d7b3b353..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/persona.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class PersonaCapabilityMixin(CapabilityRouterBridgeBase): - async def _persona_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - persona_id = str(payload.get("persona_id", "")).strip() - record = self._persona_store.get(persona_id) - if record is None: - raise AstrBotError.invalid_input(f"persona not found: {persona_id}") - return {"persona": dict(record)} - - async def _persona_list( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - personas = [ - dict(self._persona_store[persona_id]) - for persona_id in sorted(self._persona_store.keys()) - ] - return {"personas": personas} - - async def _persona_create( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - raw_persona = payload.get("persona") - if not isinstance(raw_persona, dict): - raise AstrBotError.invalid_input("persona.create requires persona object") - persona_id = str(raw_persona.get("persona_id", "")).strip() - if not persona_id: - raise AstrBotError.invalid_input("persona.create requires persona_id") - if persona_id in self._persona_store: - raise AstrBotError.invalid_input(f"persona already exists: {persona_id}") - now = self._now_iso() - record = { - "persona_id": persona_id, - "system_prompt": str(raw_persona.get("system_prompt", "")), - "begin_dialogs": self._normalize_persona_dialogs_payload( - raw_persona.get("begin_dialogs") - ), - "tools": ( - [str(item) for item in raw_persona.get("tools", [])] - if isinstance(raw_persona.get("tools"), list) - else None - ), - "skills": ( - [str(item) for item in raw_persona.get("skills", [])] - if isinstance(raw_persona.get("skills"), list) - else None - ), - "custom_error_message": ( - str(raw_persona.get("custom_error_message")) - if raw_persona.get("custom_error_message") is not None - else None - ), - "folder_id": ( - str(raw_persona.get("folder_id")) - if raw_persona.get("folder_id") is not None - else None - ), - "sort_order": int(raw_persona.get("sort_order", 0)), - "created_at": now, - "updated_at": now, - } - self._persona_store[persona_id] = record - return {"persona": dict(record)} - - async def _persona_update( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - persona_id = str(payload.get("persona_id", "")).strip() - record = self._persona_store.get(persona_id) - if record is None: - return {"persona": None} - raw_persona = payload.get("persona") - if not isinstance(raw_persona, dict): - raise AstrBotError.invalid_input("persona.update requires persona object") - if ( - "system_prompt" in raw_persona - and raw_persona.get("system_prompt") is not None - ): - record["system_prompt"] = str(raw_persona.get("system_prompt", "")) - if "begin_dialogs" in raw_persona: - begin_dialogs = raw_persona.get("begin_dialogs") - record["begin_dialogs"] = ( - self._normalize_persona_dialogs_payload(begin_dialogs) - if begin_dialogs is not None - else [] - ) - if "tools" in raw_persona: - tools = raw_persona.get("tools") - record["tools"] = ( - [str(item) for item in tools] if isinstance(tools, list) else None - ) - if "skills" in raw_persona: - skills = raw_persona.get("skills") - record["skills"] = ( - [str(item) for item in skills] if isinstance(skills, list) else None - ) - if "custom_error_message" in raw_persona: - custom_error_message = raw_persona.get("custom_error_message") - record["custom_error_message"] = ( - str(custom_error_message) if custom_error_message is not None else None - ) - record["updated_at"] = self._now_iso() - return {"persona": dict(record)} - - async def _persona_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - persona_id = str(payload.get("persona_id", "")).strip() - if persona_id not in self._persona_store: - raise AstrBotError.invalid_input(f"persona not found: {persona_id}") - del self._persona_store[persona_id] - return {} - - def _register_persona_capabilities(self) -> None: - self.register( - self._builtin_descriptor("persona.get", "获取人格"), - call_handler=self._persona_get, - ) - self.register( - self._builtin_descriptor("persona.list", "列出人格"), - call_handler=self._persona_list, - ) - self.register( - self._builtin_descriptor("persona.create", "创建人格"), - call_handler=self._persona_create, - ) - self.register( - self._builtin_descriptor("persona.update", "更新人格"), - call_handler=self._persona_update, - ) - self.register( - self._builtin_descriptor("persona.delete", "删除人格"), - call_handler=self._persona_delete, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/platform.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/platform.py deleted file mode 100644 index 01f6190ce..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/platform.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class PlatformCapabilityMixin(CapabilityRouterBridgeBase): - async def _platform_send( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session, target = self._resolve_target(payload) - text = str(payload.get("text", "")) - message_id = f"msg_{len(self.sent_messages) + 1}" - sent: dict[str, Any] = { - "message_id": message_id, - "session": session, - "text": text, - } - if target is not None: - sent["target"] = target - self.sent_messages.append(sent) - return {"message_id": message_id} - - async def _platform_send_image( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session, target = self._resolve_target(payload) - image_url = str(payload.get("image_url", "")) - message_id = f"img_{len(self.sent_messages) + 1}" - sent: dict[str, Any] = { - "message_id": message_id, - "session": session, - "image_url": image_url, - } - if target is not None: - sent["target"] = target - self.sent_messages.append(sent) - return {"message_id": message_id} - - async def _platform_send_chain( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session, target = self._resolve_target(payload) - chain = payload.get("chain") - if not isinstance(chain, list) or not all( - isinstance(item, dict) for item in chain - ): - raise AstrBotError.invalid_input( - "platform.send_chain 的 chain 必须是 object 数组" - ) - message_id = f"chain_{len(self.sent_messages) + 1}" - sent: dict[str, Any] = { - "message_id": message_id, - "session": session, - "chain": [dict(item) for item in chain], - } - if target is not None: - sent["target"] = target - self.sent_messages.append(sent) - return {"message_id": message_id} - - async def _platform_send_by_session( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - chain = payload.get("chain") - if not isinstance(chain, list) or not all( - isinstance(item, dict) for item in chain - ): - raise AstrBotError.invalid_input( - "platform.send_by_session 的 chain 必须是 object 数组" - ) - session = str(payload.get("session", "")) - message_id = f"proactive_{len(self.sent_messages) + 1}" - self.sent_messages.append( - { - "message_id": message_id, - "session": session, - "chain": [dict(item) for item in chain], - } - ) - return {"message_id": message_id} - - async def _platform_get_group( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session, _target = self._resolve_target(payload) - return {"group": self._mock_group_payload(session)} - - async def _platform_get_members( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session, _target = self._resolve_target(payload) - group = self._mock_group_payload(session) - if group is None: - return {"members": []} - return {"members": list(group.get("members", []))} - - async def _platform_list_instances( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return { - "platforms": [ - { - "id": str(item.get("id", "")), - "name": str(item.get("name", "")), - "type": str(item.get("type", "")), - "status": str(item.get("status", "unknown")), - } - for item in self.get_platform_instances() - if isinstance(item, dict) - ] - } - - def _register_platform_capabilities(self) -> None: - self.register( - self._builtin_descriptor("platform.send", "发送消息"), - call_handler=self._platform_send, - ) - self.register( - self._builtin_descriptor("platform.send_image", "发送图片"), - call_handler=self._platform_send_image, - ) - self.register( - self._builtin_descriptor("platform.send_chain", "发送消息链"), - call_handler=self._platform_send_chain, - ) - self.register( - self._builtin_descriptor( - "platform.send_by_session", "按会话主动发送消息链" - ), - call_handler=self._platform_send_by_session, - ) - self.register( - self._builtin_descriptor("platform.get_group", "获取当前群信息"), - call_handler=self._platform_get_group, - ) - self.register( - self._builtin_descriptor("platform.get_members", "获取群成员"), - call_handler=self._platform_get_members, - ) - self.register( - self._builtin_descriptor("platform.list_instances", "列出平台实例元信息"), - call_handler=self._platform_list_instances, - ) - - async def _platform_manager_get_by_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.get_by_id") - platform_id = str(payload.get("platform_id", "")).strip() - platform = next( - ( - dict(item) - for item in self._platform_instances - if str(item.get("id", "")) == platform_id - ), - None, - ) - return {"platform": platform} - - async def _platform_manager_clear_errors( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.clear_errors") - platform_id = str(payload.get("platform_id", "")).strip() - for item in self._platform_instances: - if str(item.get("id", "")) != platform_id: - continue - item["errors"] = [] - item["last_error"] = None - if str(item.get("status", "")) == "error": - item["status"] = "running" - break - return {} - - async def _platform_manager_get_stats( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.get_stats") - platform_id = str(payload.get("platform_id", "")).strip() - for item in self._platform_instances: - if str(item.get("id", "")) != platform_id: - continue - stats = item.get("stats") - if isinstance(stats, dict): - return {"stats": dict(stats)} - errors = item.get("errors") - last_error = item.get("last_error") - meta = item.get("meta") - return { - "stats": { - "id": platform_id, - "type": str(item.get("type", "")), - "display_name": str(item.get("name", platform_id)), - "status": str(item.get("status", "pending")), - "started_at": item.get("started_at"), - "error_count": len(errors) if isinstance(errors, list) else 0, - "last_error": dict(last_error) - if isinstance(last_error, dict) - else None, - "unified_webhook": bool(item.get("unified_webhook", False)), - "meta": dict(meta) if isinstance(meta, dict) else {}, - } - } - return {"stats": None} - - def _register_platform_manager_capabilities(self) -> None: - self.register( - self._builtin_descriptor( - "platform.manager.get_by_id", - "按 ID 获取平台管理快照", - ), - call_handler=self._platform_manager_get_by_id, - ) - self.register( - self._builtin_descriptor( - "platform.manager.clear_errors", - "清除平台错误", - ), - call_handler=self._platform_manager_clear_errors, - ) - self.register( - self._builtin_descriptor( - "platform.manager.get_stats", - "获取平台统计信息", - ), - call_handler=self._platform_manager_get_stats, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/provider.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/provider.py deleted file mode 100644 index 937373a0a..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/provider.py +++ /dev/null @@ -1,1080 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -from collections.abc import AsyncIterator -from typing import Any - -from ....errors import AstrBotError -from ..._streaming import StreamExecution -from ..bridge_base import ( - _MOCK_EMBEDDING_DIM, - CapabilityRouterBridgeBase, - _mock_embedding_vector, -) - - -class ProviderCapabilityMixin(CapabilityRouterBridgeBase): - @staticmethod - def _active_local_mcp_tool_names(plugin: Any | None) -> list[str]: - if plugin is None: - return [] - local_tools: list[str] = [] - for server in plugin.local_mcp_servers.values(): - if not bool(server.get("active", False)): - continue - if not bool(server.get("running", False)): - continue - server_name = str(server.get("name", "")).strip() - if not server_name: - continue - for tool_name in server.get("tools", []): - if not isinstance(tool_name, str) or not tool_name.strip(): - continue - local_tools.append(f"mcp.{server_name}.{tool_name}") - return local_tools - - def _provider_payload( - self, kind: str, provider_id: str | None - ) -> dict[str, Any] | None: - if not provider_id: - return None - for item in self._provider_catalog.get(kind, []): - if str(item.get("id", "")) == provider_id: - return dict(item) - return None - - def _provider_payload_by_id(self, provider_id: str) -> dict[str, Any] | None: - normalized = str(provider_id).strip() - if not normalized: - return None - for items in self._provider_catalog.values(): - for item in items: - if str(item.get("id", "")) == normalized: - return dict(item) - return None - - @staticmethod - def _provider_kind_from_type(provider_type: str) -> str: - mapping = { - "chat_completion": "chat", - "text_to_speech": "tts", - "speech_to_text": "stt", - "embedding": "embedding", - "rerank": "rerank", - } - normalized = str(provider_type).strip().lower() - if normalized not in mapping: - raise AstrBotError.invalid_input(f"unknown provider_type: {provider_type}") - return mapping[normalized] - - def _provider_config_by_id(self, provider_id: str) -> dict[str, Any] | None: - record = self._provider_configs.get(str(provider_id).strip()) - return dict(record) if isinstance(record, dict) else None - - @staticmethod - def _managed_provider_record( - payload: dict[str, Any], - *, - loaded: bool, - ) -> dict[str, Any]: - return { - "id": str(payload.get("id", "")), - "model": ( - str(payload.get("model")) if payload.get("model") is not None else None - ), - "type": str(payload.get("type", "")), - "provider_type": str(payload.get("provider_type", "chat_completion")), - "loaded": bool(loaded), - "enabled": bool(payload.get("enable", True)), - "provider_source_id": ( - str(payload.get("provider_source_id")) - if payload.get("provider_source_id") is not None - else None - ), - } - - def _managed_provider_record_by_id(self, provider_id: str) -> dict[str, Any] | None: - provider = self._provider_payload_by_id(provider_id) - if provider is not None: - config = self._provider_config_by_id(provider_id) or provider - merged = dict(provider) - merged.update( - { - "enable": config.get("enable", True), - "provider_source_id": config.get("provider_source_id"), - } - ) - return self._managed_provider_record(merged, loaded=True) - config = self._provider_config_by_id(provider_id) - if config is None: - return None - return self._managed_provider_record(config, loaded=False) - - def _emit_provider_change( - self, - provider_id: str, - provider_type: str, - umo: str | None, - ) -> None: - event = { - "provider_id": str(provider_id), - "provider_type": str(provider_type), - "umo": str(umo) if umo is not None else None, - } - for queue in list(self._provider_change_subscriptions.values()): - queue.put_nowait(dict(event)) - - def _require_reserved_plugin(self, capability_name: str) -> str: - plugin_id = self._require_caller_plugin_id(capability_name) - plugin = self._plugins.get(plugin_id) - if plugin is not None and bool(plugin.metadata.get("reserved", False)): - return plugin_id - if plugin_id in {"system", "__system__"}: - return plugin_id - raise AstrBotError.invalid_input( - f"{capability_name} is restricted to reserved/system plugins" - ) - - def _provider_entry( - self, - payload: dict[str, Any], - capability_name: str, - expected_kind: str | None = None, - ) -> dict[str, Any]: - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - f"{capability_name} requires provider_id", - ) - provider = self._provider_payload_by_id(provider_id) - if provider is None: - raise AstrBotError.invalid_input( - f"{capability_name} unknown provider_id: {provider_id}", - ) - if ( - expected_kind is not None - and str(provider.get("provider_type")) != expected_kind - ): - raise AstrBotError.invalid_input( - f"{capability_name} requires a {expected_kind} provider", - ) - return provider - - async def _provider_get_using( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider_id = self._active_provider_ids.get("chat") - return {"provider": self._provider_payload("chat", provider_id)} - - async def _provider_get_by_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - return { - "provider": self._provider_payload_by_id( - str(payload.get("provider_id", "")) - ) - } - - async def _provider_get_current_chat_provider_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - return {"provider_id": self._active_provider_ids.get("chat")} - - def _provider_list_payload(self, kind: str) -> dict[str, Any]: - return { - "providers": [dict(item) for item in self._provider_catalog.get(kind, [])] - } - - async def _provider_list_all( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return self._provider_list_payload("chat") - - async def _provider_list_all_tts( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return self._provider_list_payload("tts") - - async def _provider_list_all_stt( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return self._provider_list_payload("stt") - - async def _provider_list_all_embedding( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return self._provider_list_payload("embedding") - - async def _provider_list_all_rerank( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - return self._provider_list_payload("rerank") - - async def _provider_get_using_tts( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider_id = self._active_provider_ids.get("tts") - return {"provider": self._provider_payload("tts", provider_id)} - - async def _provider_get_using_stt( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider_id = self._active_provider_ids.get("stt") - return {"provider": self._provider_payload("stt", provider_id)} - - async def _provider_stt_get_text( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._provider_entry( - payload, - "provider.stt.get_text", - "speech_to_text", - ) - return {"text": f"Mock transcript: {str(payload.get('audio_url', ''))}"} - - async def _provider_tts_get_audio( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider = self._provider_entry( - payload, - "provider.tts.get_audio", - "text_to_speech", - ) - return { - "audio_path": ( - f"mock://tts/{provider.get('id', '')}/{str(payload.get('text', ''))}" - ) - } - - async def _provider_tts_support_stream( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider = self._provider_entry( - payload, - "provider.tts.support_stream", - "text_to_speech", - ) - return {"supported": bool(provider.get("support_stream", True))} - - async def _provider_tts_get_audio_stream( - self, - _request_id: str, - payload: dict[str, Any], - token, - ) -> StreamExecution: - self._provider_entry( - payload, - "provider.tts.get_audio_stream", - "text_to_speech", - ) - text = payload.get("text") - text_chunks = payload.get("text_chunks") - if isinstance(text, str): - chunks = [text] - elif isinstance(text_chunks, list) and text_chunks: - chunks = [str(item) for item in text_chunks] - else: - raise AstrBotError.invalid_input( - "provider.tts.get_audio_stream requires text or text_chunks" - ) - - async def iterator() -> AsyncIterator[dict[str, Any]]: - for chunk in chunks: - token.raise_if_cancelled() - await asyncio.sleep(0) - yield { - "audio_base64": base64.b64encode( - f"mock-audio:{chunk}".encode() - ).decode("ascii"), - "text": chunk, - } - - return StreamExecution( - iterator=iterator(), - finalize=lambda items: ( - items[-1] if items else {"audio_base64": "", "text": None} - ), - ) - - async def _provider_embedding_get_embedding( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider = self._provider_entry( - payload, - "provider.embedding.get_embedding", - "embedding", - ) - return { - "embedding": _mock_embedding_vector( - str(payload.get("text", "")), - provider_id=str(provider.get("id", "")), - ) - } - - async def _provider_embedding_get_embeddings( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - provider = self._provider_entry( - payload, - "provider.embedding.get_embeddings", - "embedding", - ) - texts = payload.get("texts") - if not isinstance(texts, list): - raise AstrBotError.invalid_input( - "provider.embedding.get_embeddings requires texts", - ) - return { - "embeddings": [ - _mock_embedding_vector( - str(text), - provider_id=str(provider.get("id", "")), - ) - for text in texts - ], - } - - async def _provider_embedding_get_dim( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._provider_entry( - payload, - "provider.embedding.get_dim", - "embedding", - ) - return {"dim": _MOCK_EMBEDDING_DIM} - - async def _provider_rerank_rerank( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._provider_entry( - payload, - "provider.rerank.rerank", - "rerank", - ) - documents = payload.get("documents") - if not isinstance(documents, list): - raise AstrBotError.invalid_input( - "provider.rerank.rerank requires documents", - ) - scored = [ - { - "index": index, - "score": 1.0, - "document": str(raw_document), - } - for index, raw_document in enumerate(documents) - ] - top_n = payload.get("top_n") - if top_n is not None: - scored = scored[: max(int(top_n), 0)] - return {"results": scored} - - async def _provider_manager_set( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.set") - provider_id = str(payload.get("provider_id", "")).strip() - provider_type = str(payload.get("provider_type", "")).strip() - kind = self._provider_kind_from_type(provider_type) - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.set requires provider_id" - ) - if self._provider_payload(kind, provider_id) is None: - raise AstrBotError.invalid_input( - f"provider.manager.set unknown provider_id: {provider_id}" - ) - self._active_provider_ids[kind] = provider_id - self._emit_provider_change( - provider_id, - provider_type, - str(payload.get("umo")) if payload.get("umo") is not None else None, - ) - return {} - - async def _provider_manager_get_by_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.get_by_id") - return { - "provider": self._managed_provider_record_by_id( - str(payload.get("provider_id", "")) - ) - } - - async def _provider_manager_get_merged_provider_config( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.get_merged_provider_config") - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.get_merged_provider_config requires provider_id" - ) - provider = self._provider_payload_by_id(provider_id) - config = self._provider_config_by_id(provider_id) - if provider is None and config is None: - raise AstrBotError.invalid_input( - "provider.manager.get_merged_provider_config " - f"unknown provider_id: {provider_id}" - ) - if provider is None: - return {"config": dict(config) if isinstance(config, dict) else config} - if config is None: - return {"config": dict(provider)} - merged_config = dict(provider) - merged_config.update(config) - return {"config": merged_config} - - @staticmethod - def _normalize_provider_config_object( - payload: Any, - capability_name: str, - field_name: str, - ) -> dict[str, Any]: - if not isinstance(payload, dict): - raise AstrBotError.invalid_input( - f"{capability_name} requires {field_name} object" - ) - return dict(payload) - - async def _provider_manager_load( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.load") - provider_config = self._normalize_provider_config_object( - payload.get("provider_config"), - "provider.manager.load", - "provider_config", - ) - provider_id = str(provider_config.get("id", "")).strip() - provider_type = str(provider_config.get("provider_type", "")).strip() - kind = self._provider_kind_from_type(provider_type) - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.load requires provider id" - ) - if bool(provider_config.get("enable", True)): - record = { - "id": provider_id, - "model": ( - str(provider_config.get("model")) - if provider_config.get("model") is not None - else None - ), - "type": str(provider_config.get("type", "")), - "provider_type": provider_type, - } - self._provider_catalog[kind] = [ - item - for item in self._provider_catalog.get(kind, []) - if str(item.get("id", "")) != provider_id - ] - self._provider_catalog[kind].append(record) - self._emit_provider_change(provider_id, provider_type, None) - return { - "provider": self._managed_provider_record( - provider_config, - loaded=bool(provider_config.get("enable", True)), - ) - } - - async def _provider_manager_terminate( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.terminate") - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.terminate requires provider_id" - ) - managed = self._managed_provider_record_by_id(provider_id) - if managed is None: - raise AstrBotError.invalid_input( - f"provider.manager.terminate unknown provider_id: {provider_id}" - ) - kind = self._provider_kind_from_type(str(managed.get("provider_type", ""))) - self._provider_catalog[kind] = [ - item - for item in self._provider_catalog.get(kind, []) - if str(item.get("id", "")) != provider_id - ] - if self._active_provider_ids.get(kind) == provider_id: - catalog = self._provider_catalog.get(kind, []) - self._active_provider_ids[kind] = ( - str(catalog[0].get("id")) if catalog else None - ) - self._emit_provider_change( - provider_id, str(managed.get("provider_type", "")), None - ) - return {} - - async def _provider_manager_create( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.create") - provider_config = self._normalize_provider_config_object( - payload.get("provider_config"), - "provider.manager.create", - "provider_config", - ) - provider_id = str(provider_config.get("id", "")).strip() - provider_type = str(provider_config.get("provider_type", "")).strip() - kind = self._provider_kind_from_type(provider_type) - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.create requires provider id" - ) - self._provider_configs[provider_id] = dict(provider_config) - if bool(provider_config.get("enable", True)): - self._provider_catalog[kind] = [ - item - for item in self._provider_catalog.get(kind, []) - if str(item.get("id", "")) != provider_id - ] - self._provider_catalog[kind].append( - { - "id": provider_id, - "model": ( - str(provider_config.get("model")) - if provider_config.get("model") is not None - else None - ), - "type": str(provider_config.get("type", "")), - "provider_type": provider_type, - } - ) - self._emit_provider_change(provider_id, provider_type, None) - return {"provider": self._managed_provider_record_by_id(provider_id)} - - async def _provider_manager_update( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.update") - origin_provider_id = str(payload.get("origin_provider_id", "")).strip() - new_config = self._normalize_provider_config_object( - payload.get("new_config"), - "provider.manager.update", - "new_config", - ) - if not origin_provider_id: - raise AstrBotError.invalid_input( - "provider.manager.update requires origin_provider_id" - ) - current = self._provider_config_by_id(origin_provider_id) - if current is None: - current = self._managed_provider_record_by_id(origin_provider_id) - if current is None: - raise AstrBotError.invalid_input( - f"provider.manager.update unknown provider_id: {origin_provider_id}" - ) - target_provider_id = str(new_config.get("id") or origin_provider_id).strip() - provider_type = str( - new_config.get("provider_type") or current.get("provider_type", "") - ).strip() - kind = self._provider_kind_from_type(provider_type) - self._provider_configs.pop(origin_provider_id, None) - merged = dict(current) - merged.update(new_config) - merged["id"] = target_provider_id - merged["provider_type"] = provider_type - self._provider_configs[target_provider_id] = merged - for catalog_kind, items in list(self._provider_catalog.items()): - self._provider_catalog[catalog_kind] = [ - item for item in items if str(item.get("id", "")) != origin_provider_id - ] - if bool(merged.get("enable", True)): - self._provider_catalog[kind].append( - { - "id": target_provider_id, - "model": ( - str(merged.get("model")) - if merged.get("model") is not None - else None - ), - "type": str(merged.get("type", "")), - "provider_type": provider_type, - } - ) - for active_kind, active_id in list(self._active_provider_ids.items()): - if active_id == origin_provider_id: - self._active_provider_ids[active_kind] = ( - target_provider_id if active_kind == kind else None - ) - self._emit_provider_change(target_provider_id, provider_type, None) - return {"provider": self._managed_provider_record_by_id(target_provider_id)} - - async def _provider_manager_delete( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.delete") - provider_id = ( - str(payload.get("provider_id")).strip() - if payload.get("provider_id") is not None - else None - ) - provider_source_id = ( - str(payload.get("provider_source_id")).strip() - if payload.get("provider_source_id") is not None - else None - ) - if not provider_id and not provider_source_id: - raise AstrBotError.invalid_input( - "provider.manager.delete requires provider_id or provider_source_id" - ) - deleted: list[dict[str, Any]] = [] - if provider_id: - record = self._managed_provider_record_by_id(provider_id) - if record is not None: - deleted.append(record) - self._provider_configs.pop(provider_id, None) - else: - for record_id, record in list(self._provider_configs.items()): - if ( - str(record.get("provider_source_id", "")).strip() - != provider_source_id - ): - continue - deleted_record = self._managed_provider_record_by_id(record_id) - if deleted_record is not None: - deleted.append(deleted_record) - self._provider_configs.pop(record_id, None) - deleted_ids = {str(item.get("id", "")) for item in deleted} - for kind, items in list(self._provider_catalog.items()): - self._provider_catalog[kind] = [ - item for item in items if str(item.get("id", "")) not in deleted_ids - ] - if self._active_provider_ids.get(kind) in deleted_ids: - catalog = self._provider_catalog.get(kind, []) - self._active_provider_ids[kind] = ( - str(catalog[0].get("id")) if catalog else None - ) - for record in deleted: - self._emit_provider_change( - str(record.get("id", "")), - str(record.get("provider_type", "")), - None, - ) - return {} - - async def _provider_manager_get_insts( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("provider.manager.get_insts") - return { - "providers": [ - self._managed_provider_record(item, loaded=True) - for item in self._provider_catalog.get("chat", []) - ] - } - - async def _provider_manager_watch_changes( - self, request_id: str, _payload: dict[str, Any], _token - ) -> StreamExecution: - self._require_reserved_plugin("provider.manager.watch_changes") - queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - self._provider_change_subscriptions[request_id] = queue - - async def iterator() -> AsyncIterator[dict[str, Any]]: - try: - while True: - yield await queue.get() - finally: - self._provider_change_subscriptions.pop(request_id, None) - - return StreamExecution( - iterator=iterator(), - finalize=lambda _chunks: {}, - collect_chunks=False, - ) - - async def _platform_manager_get_by_id( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.get_by_id") - platform_id = str(payload.get("platform_id", "")).strip() - platform = next( - ( - dict(item) - for item in self._platform_instances - if str(item.get("id", "")) == platform_id - ), - None, - ) - return {"platform": platform} - - async def _platform_manager_clear_errors( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.clear_errors") - platform_id = str(payload.get("platform_id", "")).strip() - for item in self._platform_instances: - if str(item.get("id", "")) != platform_id: - continue - item["errors"] = [] - item["last_error"] = None - if str(item.get("status", "")) == "error": - item["status"] = "running" - break - return {} - - async def _platform_manager_get_stats( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self._require_reserved_plugin("platform.manager.get_stats") - platform_id = str(payload.get("platform_id", "")).strip() - for item in self._platform_instances: - if str(item.get("id", "")) != platform_id: - continue - stats = item.get("stats") - if isinstance(stats, dict): - return {"stats": dict(stats)} - errors = item.get("errors") - last_error = item.get("last_error") - meta = item.get("meta") - return { - "stats": { - "id": platform_id, - "type": str(item.get("type", "")), - "display_name": str(item.get("name", platform_id)), - "status": str(item.get("status", "pending")), - "started_at": item.get("started_at"), - "error_count": len(errors) if isinstance(errors, list) else 0, - "last_error": dict(last_error) - if isinstance(last_error, dict) - else None, - "unified_webhook": bool(item.get("unified_webhook", False)), - "meta": dict(meta) if isinstance(meta, dict) else {}, - } - } - return {"stats": None} - - async def _llm_tool_manager_get( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("llm_tool.manager.get") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"registered": [], "active": []} - registered = [dict(item) for item in plugin.llm_tools.values()] - active = [ - dict(item) - for name, item in plugin.llm_tools.items() - if name in plugin.active_llm_tools - ] - return {"registered": registered, "active": active} - - async def _llm_tool_manager_activate( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("llm_tool.manager.activate") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"activated": False} - name = str(payload.get("name", "")) - spec = plugin.llm_tools.get(name) - if spec is None: - return {"activated": False} - spec["active"] = True - plugin.active_llm_tools.add(name) - return {"activated": True} - - async def _llm_tool_manager_deactivate( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("llm_tool.manager.deactivate") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"deactivated": False} - name = str(payload.get("name", "")) - spec = plugin.llm_tools.get(name) - if spec is None: - return {"deactivated": False} - spec["active"] = False - plugin.active_llm_tools.discard(name) - return {"deactivated": True} - - async def _llm_tool_manager_add( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("llm_tool.manager.add") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"names": []} - tools_payload = payload.get("tools") - if not isinstance(tools_payload, list): - raise AstrBotError.invalid_input("llm_tool.manager.add 的 tools 必须是数组") - names: list[str] = [] - for item in tools_payload: - if not isinstance(item, dict): - continue - name = str(item.get("name", "")).strip() - if not name: - continue - plugin.llm_tools[name] = dict(item) - if bool(item.get("active", True)): - plugin.active_llm_tools.add(name) - else: - plugin.active_llm_tools.discard(name) - names.append(name) - return {"names": names} - - async def _llm_tool_manager_remove( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("llm_tool.manager.remove") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"removed": False} - name = str(payload.get("name", "")).strip() - removed = plugin.llm_tools.pop(name, None) is not None - plugin.active_llm_tools.discard(name) - return {"removed": removed} - - async def _agent_registry_list( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("agent.registry.list") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"agents": []} - return {"agents": [dict(item) for item in plugin.agents.values()]} - - async def _agent_registry_get( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("agent.registry.get") - plugin = self._plugins.get(plugin_id) - if plugin is None: - return {"agent": None} - agent = plugin.agents.get(str(payload.get("name", ""))) - return {"agent": dict(agent) if isinstance(agent, dict) else None} - - async def _agent_tool_loop_run( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("agent.tool_loop.run") - plugin = self._plugins.get(plugin_id) - requested_tools = payload.get("tool_names") - active_tools: list[str] = [] - if plugin is not None: - local_tools = self._active_local_mcp_tool_names(plugin) - if isinstance(requested_tools, list) and requested_tools: - active_tools = [ - name - for name in (str(item) for item in requested_tools) - if name in plugin.active_llm_tools or name in local_tools - ] - else: - active_tools = sorted([*plugin.active_llm_tools, *local_tools]) - prompt = str(payload.get("prompt", "") or "") - suffix = "" - if active_tools: - suffix = f" tools={','.join(active_tools)}" - return { - "text": f"Mock tool loop: {prompt}{suffix}".strip(), - "usage": { - "input_tokens": len(prompt), - "output_tokens": len(prompt) + len(suffix), - }, - "finish_reason": "stop", - "tool_calls": [], - "role": "assistant", - "reasoning_content": None, - "reasoning_signature": None, - } - - def _register_provider_capabilities(self) -> None: - self.register( - self._builtin_descriptor("provider.get_using", "获取当前聊天 Provider"), - call_handler=self._provider_get_using, - ) - self.register( - self._builtin_descriptor("provider.get_by_id", "按 ID 获取 Provider"), - call_handler=self._provider_get_by_id, - ) - self.register( - self._builtin_descriptor( - "provider.get_current_chat_provider_id", - "获取当前聊天 Provider ID", - ), - call_handler=self._provider_get_current_chat_provider_id, - ) - self.register( - self._builtin_descriptor("provider.list_all", "列出聊天 Providers"), - call_handler=self._provider_list_all, - ) - self.register( - self._builtin_descriptor("provider.list_all_tts", "列出 TTS Providers"), - call_handler=self._provider_list_all_tts, - ) - self.register( - self._builtin_descriptor("provider.list_all_stt", "列出 STT Providers"), - call_handler=self._provider_list_all_stt, - ) - self.register( - self._builtin_descriptor( - "provider.list_all_embedding", - "列出 Embedding Providers", - ), - call_handler=self._provider_list_all_embedding, - ) - self.register( - self._builtin_descriptor( - "provider.list_all_rerank", - "列出 Rerank Providers", - ), - call_handler=self._provider_list_all_rerank, - ) - self.register( - self._builtin_descriptor("provider.get_using_tts", "获取当前 TTS Provider"), - call_handler=self._provider_get_using_tts, - ) - self.register( - self._builtin_descriptor("provider.get_using_stt", "获取当前 STT Provider"), - call_handler=self._provider_get_using_stt, - ) - self.register( - self._builtin_descriptor("provider.stt.get_text", "STT 转写"), - call_handler=self._provider_stt_get_text, - ) - self.register( - self._builtin_descriptor("provider.tts.get_audio", "TTS 合成音频"), - call_handler=self._provider_tts_get_audio, - ) - self.register( - self._builtin_descriptor( - "provider.tts.support_stream", - "检查 TTS 流式支持", - ), - call_handler=self._provider_tts_support_stream, - ) - self.register( - self._builtin_descriptor( - "provider.tts.get_audio_stream", - "流式 TTS 音频输出", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._provider_tts_get_audio_stream, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_embedding", - "获取单条向量", - ), - call_handler=self._provider_embedding_get_embedding, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_embeddings", - "批量获取向量", - ), - call_handler=self._provider_embedding_get_embeddings, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_dim", - "获取向量维度", - ), - call_handler=self._provider_embedding_get_dim, - ) - self.register( - self._builtin_descriptor("provider.rerank.rerank", "文档重排序"), - call_handler=self._provider_rerank_rerank, - ) - - def _register_provider_manager_capabilities(self) -> None: - self.register( - self._builtin_descriptor("provider.manager.set", "设置当前 Provider"), - call_handler=self._provider_manager_set, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_by_id", - "按 ID 获取 Provider 管理记录", - ), - call_handler=self._provider_manager_get_by_id, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_merged_provider_config", - "获取 Provider 合并配置", - ), - call_handler=self._provider_manager_get_merged_provider_config, - ) - self.register( - self._builtin_descriptor("provider.manager.load", "运行时加载 Provider"), - call_handler=self._provider_manager_load, - ) - self.register( - self._builtin_descriptor( - "provider.manager.terminate", - "终止已加载的 Provider", - ), - call_handler=self._provider_manager_terminate, - ) - self.register( - self._builtin_descriptor("provider.manager.create", "创建 Provider"), - call_handler=self._provider_manager_create, - ) - self.register( - self._builtin_descriptor("provider.manager.update", "更新 Provider"), - call_handler=self._provider_manager_update, - ) - self.register( - self._builtin_descriptor("provider.manager.delete", "删除 Provider"), - call_handler=self._provider_manager_delete, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_insts", - "列出已加载聊天 Provider", - ), - call_handler=self._provider_manager_get_insts, - ) - self.register( - self._builtin_descriptor( - "provider.manager.watch_changes", - "订阅 Provider 变更", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._provider_manager_watch_changes, - ) - - def _register_agent_tool_capabilities(self) -> None: - self.register( - self._builtin_descriptor("llm_tool.manager.get", "获取 LLM 工具状态"), - call_handler=self._llm_tool_manager_get, - ) - self.register( - self._builtin_descriptor("llm_tool.manager.activate", "激活 LLM 工具"), - call_handler=self._llm_tool_manager_activate, - ) - self.register( - self._builtin_descriptor("llm_tool.manager.deactivate", "停用 LLM 工具"), - call_handler=self._llm_tool_manager_deactivate, - ) - self.register( - self._builtin_descriptor("llm_tool.manager.add", "动态添加 LLM 工具"), - call_handler=self._llm_tool_manager_add, - ) - self.register( - self._builtin_descriptor("llm_tool.manager.remove", "动态移除 LLM 工具"), - call_handler=self._llm_tool_manager_remove, - ) - self.register( - self._builtin_descriptor("agent.tool_loop.run", "运行 mock tool loop"), - call_handler=self._agent_tool_loop_run, - ) - self.register( - self._builtin_descriptor("agent.registry.list", "列出 Agent 元数据"), - call_handler=self._agent_registry_list, - ) - self.register( - self._builtin_descriptor("agent.registry.get", "获取 Agent 元数据"), - call_handler=self._agent_registry_get, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/session.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/session.py deleted file mode 100644 index e56f979e9..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/session.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class SessionCapabilityMixin(CapabilityRouterBridgeBase): - async def _session_plugin_is_enabled( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - plugin_name = str(payload.get("plugin_name", "")) - config = self._session_plugin_config(session) - enabled_plugins = { - str(item) for item in config.get("enabled_plugins", []) if str(item).strip() - } - disabled_plugins = { - str(item) - for item in config.get("disabled_plugins", []) - if str(item).strip() - } - if plugin_name in enabled_plugins: - return {"enabled": True} - return {"enabled": plugin_name not in disabled_plugins} - - async def _session_plugin_filter_handlers( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - handlers = payload.get("handlers") - if not isinstance(handlers, list): - raise AstrBotError.invalid_input( - "session.plugin.filter_handlers 的 handlers 必须是 object 数组" - ) - disabled_plugins = { - str(item) - for item in self._session_plugin_config(session).get("disabled_plugins", []) - if str(item).strip() - } - reserved_plugins = { - str(plugin.metadata.get("name", "")) - for plugin in self._plugins.values() - if bool(plugin.metadata.get("reserved", False)) - } - filtered = [] - for item in handlers: - if not isinstance(item, dict): - continue - plugin_name = str(item.get("plugin_name", "")) - if ( - plugin_name - and plugin_name in disabled_plugins - and plugin_name not in reserved_plugins - ): - continue - filtered.append(dict(item)) - return {"handlers": filtered} - - async def _session_service_is_llm_enabled( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - config = self._session_service_config(session) - return {"enabled": bool(config.get("llm_enabled", True))} - - async def _session_service_set_llm_status( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - config = self._session_service_config(session) - config["llm_enabled"] = bool(payload.get("enabled", False)) - self._session_service_configs[session] = config - return {} - - async def _session_service_is_tts_enabled( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - config = self._session_service_config(session) - return {"enabled": bool(config.get("tts_enabled", True))} - - async def _session_service_set_tts_status( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - session = str(payload.get("session", "")) - config = self._session_service_config(session) - config["tts_enabled"] = bool(payload.get("enabled", False)) - self._session_service_configs[session] = config - return {} - - def _register_session_capabilities(self) -> None: - self.register( - self._builtin_descriptor("session.plugin.is_enabled", "获取会话级插件开关"), - call_handler=self._session_plugin_is_enabled, - ) - self.register( - self._builtin_descriptor( - "session.plugin.filter_handlers", - "按会话过滤 handler 元数据", - ), - call_handler=self._session_plugin_filter_handlers, - ) - self.register( - self._builtin_descriptor( - "session.service.is_llm_enabled", - "获取会话级 LLM 开关", - ), - call_handler=self._session_service_is_llm_enabled, - ) - self.register( - self._builtin_descriptor( - "session.service.set_llm_status", - "写入会话级 LLM 开关", - ), - call_handler=self._session_service_set_llm_status, - ) - self.register( - self._builtin_descriptor( - "session.service.is_tts_enabled", - "获取会话级 TTS 开关", - ), - call_handler=self._session_service_is_tts_enabled, - ) - self.register( - self._builtin_descriptor( - "session.service.set_tts_status", - "写入会话级 TTS 开关", - ), - call_handler=self._session_service_set_tts_status, - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/skill.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/skill.py deleted file mode 100644 index 942f69698..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/skill.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import CapabilityRouterBridgeBase - - -class SkillCapabilityMixin(CapabilityRouterBridgeBase): - def _register_skill_capabilities(self) -> None: - self.register( - self._builtin_descriptor("skill.register", "注册插件 skill"), - call_handler=self._skill_register, - ) - self.register( - self._builtin_descriptor("skill.unregister", "注销插件 skill"), - call_handler=self._skill_unregister, - ) - self.register( - self._builtin_descriptor("skill.list", "列出插件 skill"), - call_handler=self._skill_list, - ) - - async def _skill_register( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, str]: - plugin_id = self._require_caller_plugin_id("skill.register") - plugin = self._plugins.get(plugin_id) - if plugin is None: - raise AstrBotError.invalid_input(f"Unknown plugin: {plugin_id}") - - skill_name = str(payload.get("name", "")).strip() - if not skill_name: - raise AstrBotError.invalid_input("skill.register requires name") - skill_path = str(payload.get("path", "")).strip() - if not skill_path: - raise AstrBotError.invalid_input("skill.register requires path") - - path_obj = Path(skill_path) - skill_dir = path_obj.parent if path_obj.name == "SKILL.md" else path_obj - - entry = { - "name": skill_name, - "description": str(payload.get("description", "") or ""), - "path": skill_path, - "skill_dir": str(skill_dir), - } - plugin.skills[skill_name] = entry - return dict(entry) - - async def _skill_unregister( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, bool]: - plugin_id = self._require_caller_plugin_id("skill.unregister") - plugin = self._plugins.get(plugin_id) - if plugin is None: - raise AstrBotError.invalid_input(f"Unknown plugin: {plugin_id}") - removed = ( - plugin.skills.pop(str(payload.get("name", "")).strip(), None) is not None - ) - return {"removed": removed} - - async def _skill_list( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, list[dict[str, str]]]: - plugin_id = self._require_caller_plugin_id("skill.list") - plugin = self._plugins.get(plugin_id) - if plugin is None: - raise AstrBotError.invalid_input(f"Unknown plugin: {plugin_id}") - return { - "skills": [ - dict(plugin.skills[name]) for name in sorted(plugin.skills.keys()) - ] - } diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/system.py b/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/system.py deleted file mode 100644 index cced4a1d3..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_capability_router_builtins/capabilities/system.py +++ /dev/null @@ -1,491 +0,0 @@ -from __future__ import annotations - -import json -import uuid -from typing import Any - -from ....errors import AstrBotError -from ..bridge_base import ( - CapabilityRouterBridgeBase, - _clone_chain_payload, - _clone_target_payload, -) - - -class SystemCapabilityMixin(CapabilityRouterBridgeBase): - @staticmethod - def _overlay_request_id(request_id: str, payload: dict[str, Any]) -> str: - scope_request_id = payload.get("_request_scope_id") - if isinstance(scope_request_id, str) and scope_request_id.strip(): - return scope_request_id - return request_id - - def _register_system_capabilities(self) -> None: - self.register( - self._builtin_descriptor("system.get_data_dir", "获取插件数据目录"), - call_handler=self._system_get_data_dir, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.text_to_image", "文本转图片"), - call_handler=self._system_text_to_image, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.html_render", "渲染 HTML 模板"), - call_handler=self._system_html_render, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.file.register", "注册文件令牌"), - call_handler=self._system_file_register, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.file.handle", "解析文件令牌"), - call_handler=self._system_file_handle, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.session_waiter.register", - "注册会话等待器", - ), - call_handler=self._system_session_waiter_register, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.session_waiter.unregister", - "注销会话等待器", - ), - call_handler=self._system_session_waiter_unregister, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.react", "发送事件表情回应"), - call_handler=self._system_event_react, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.send_typing", "发送输入中状态"), - call_handler=self._system_event_send_typing, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming", - "发送事件流式消息", - ), - call_handler=self._system_event_send_streaming, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming_chunk", - "推送事件流式消息分片", - ), - call_handler=self._system_event_send_streaming_chunk, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming_close", - "关闭事件流式消息会话", - ), - call_handler=self._system_event_send_streaming_close, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.llm.get_state", - "读取当前请求的默认 LLM 状态", - ), - call_handler=self._system_event_llm_get_state, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.llm.request", - "请求当前事件继续进入默认 LLM 链路", - ), - call_handler=self._system_event_llm_request, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.result.get", "读取当前请求结果"), - call_handler=self._system_event_result_get, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.result.set", "写入当前请求结果"), - call_handler=self._system_event_result_set, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.result.clear", "清理当前请求结果"), - call_handler=self._system_event_result_clear, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.handler_whitelist.get", - "读取当前请求 handler 白名单", - ), - call_handler=self._system_event_handler_whitelist_get, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.handler_whitelist.set", - "写入当前请求 handler 白名单", - ), - call_handler=self._system_event_handler_whitelist_set, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "registry.get_handlers_by_event_type", - "按事件类型列出 handler 元数据", - ), - call_handler=self._registry_get_handlers_by_event_type, - ) - self.register( - self._builtin_descriptor( - "registry.get_handler_by_full_name", - "按 full name 查询 handler 元数据", - ), - call_handler=self._registry_get_handler_by_full_name, - ) - self.register( - self._builtin_descriptor( - "registry.command.register", - "注册动态命令路由", - ), - call_handler=self._registry_command_register, - ) - - def _ensure_request_overlay(self, request_id: str) -> dict[str, Any]: - overlay = self._request_overlays.get(request_id) - if overlay is None: - overlay = { - "should_call_llm": False, - "requested_llm": False, - "result": None, - "handler_whitelist": None, - } - self._request_overlays[request_id] = overlay - return overlay - - async def _system_get_data_dir( - self, _request_id: str, _payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("system.get_data_dir") - data_dir = self._plugin_data_dir( - plugin_id, - capability_name="system.get_data_dir", - ) - data_dir.mkdir(parents=True, exist_ok=True) - return {"path": str(data_dir)} - - async def _system_text_to_image( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - text = str(payload.get("text", "")) - if bool(payload.get("return_url", True)): - return {"result": f"mock://text_to_image/{text}"} - return {"result": f"{text}"} - - async def _system_html_render( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - tmpl = str(payload.get("tmpl", "")) - data = payload.get("data") - if not isinstance(data, dict): - raise AstrBotError.invalid_input("system.html_render requires object data") - if bool(payload.get("return_url", True)): - return {"result": f"mock://html_render/{tmpl}"} - return {"result": json.dumps({"tmpl": tmpl, "data": data}, ensure_ascii=False)} - - async def _system_file_register( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - path = str(payload.get("path", "")).strip() - if not path: - raise AstrBotError.invalid_input("system.file.register requires path") - file_token = uuid.uuid4().hex - self._file_token_store[file_token] = path - return {"token": file_token, "url": f"mock://file/{file_token}"} - - async def _system_file_handle( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - file_token = str(payload.get("token", "")).strip() - if not file_token: - raise AstrBotError.invalid_input("system.file.handle requires token") - path = self._file_token_store.pop(file_token, None) - if path is None: - raise AstrBotError.invalid_input(f"Unknown file token: {file_token}") - return {"path": path} - - async def _system_event_llm_get_state( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay = self._ensure_request_overlay( - self._overlay_request_id(request_id, payload) - ) - return { - "should_call_llm": bool(overlay["should_call_llm"]), - "requested_llm": bool(overlay["requested_llm"]), - } - - async def _system_event_llm_request( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - overlay = self._ensure_request_overlay(overlay_request_id) - overlay["requested_llm"] = True - overlay["should_call_llm"] = True - return await self._system_event_llm_get_state( - request_id, - {"_request_scope_id": overlay_request_id}, - _token, - ) - - async def _system_event_result_get( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay = self._ensure_request_overlay( - self._overlay_request_id(request_id, payload) - ) - result = overlay.get("result") - return {"result": dict(result) if isinstance(result, dict) else None} - - async def _system_event_result_set( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - result = payload.get("result") - if not isinstance(result, dict): - raise AstrBotError.invalid_input( - "system.event.result.set 的 result 必须是 object" - ) - overlay = self._ensure_request_overlay( - self._overlay_request_id(request_id, payload) - ) - overlay["result"] = dict(result) - return {"result": dict(result)} - - async def _system_event_result_clear( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay = self._ensure_request_overlay( - self._overlay_request_id(request_id, payload) - ) - overlay["result"] = None - return {} - - async def _system_event_handler_whitelist_get( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay = self._ensure_request_overlay( - self._overlay_request_id(request_id, payload) - ) - whitelist = overlay.get("handler_whitelist") - if whitelist is None: - return {"plugin_names": None} - return {"plugin_names": sorted(str(item) for item in whitelist)} - - async def _system_event_handler_whitelist_set( - self, request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - overlay = self._ensure_request_overlay(overlay_request_id) - plugin_names_payload = payload.get("plugin_names") - if plugin_names_payload is None: - overlay["handler_whitelist"] = None - elif isinstance(plugin_names_payload, list): - overlay["handler_whitelist"] = { - str(item) for item in plugin_names_payload if str(item).strip() - } - else: - raise AstrBotError.invalid_input( - "system.event.handler_whitelist.set 的 plugin_names 必须是数组或 null" - ) - return await self._system_event_handler_whitelist_get( - request_id, - {"_request_scope_id": overlay_request_id}, - _token, - ) - - async def _registry_get_handlers_by_event_type( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - event_type = str(payload.get("event_type", "")).strip() - handlers: list[dict[str, Any]] = [] - for plugin in self._plugins.values(): - handlers.extend( - [ - dict(handler) - for handler in plugin.handlers - if event_type in handler.get("event_types", []) - ] - ) - if event_type == "message": - for plugin_name, routes in self._dynamic_command_routes.items(): - for route in routes: - if not isinstance(route, dict): - continue - handlers.append( - { - "plugin_name": str(route.get("plugin_name", plugin_name)), - "handler_full_name": str( - route.get("handler_full_name", "") - ), - "trigger_type": ( - "message" - if bool(route.get("use_regex", False)) - else "command" - ), - "description": ( - None - if route.get("desc") is None - else str(route.get("desc", "")).strip() or None - ), - "event_types": ["message"], - "enabled": True, - "group_path": [], - "priority": int(route.get("priority", 0) or 0), - "kind": "handler", - "require_admin": False, - } - ) - return {"handlers": handlers} - - async def _registry_get_handler_by_full_name( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - full_name = str(payload.get("full_name", "")).strip() - for plugin in self._plugins.values(): - for handler in plugin.handlers: - if handler.get("handler_full_name") == full_name: - return {"handler": dict(handler)} - return {"handler": None} - - async def _registry_command_register( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - source_event_type = str(payload.get("source_event_type", "")).strip() - if source_event_type not in {"astrbot_loaded", "platform_loaded"}: - raise AstrBotError.invalid_input( - "register_commands is only available in astrbot_loaded/platform_loaded events" - ) - if bool(payload.get("ignore_prefix", False)): - raise AstrBotError.invalid_input( - "register_commands(ignore_prefix=True) is unsupported in SDK runtime" - ) - priority_value = payload.get("priority", 0) - if isinstance(priority_value, bool) or not isinstance(priority_value, int): - raise AstrBotError.invalid_input( - "registry.command.register 的 priority 必须是 integer" - ) - plugin_id = self._require_caller_plugin_id("registry.command.register") - self.register_dynamic_command_route( - plugin_id=plugin_id, - command_name=str(payload.get("command_name", "")), - handler_full_name=str(payload.get("handler_full_name", "")), - desc=str(payload.get("desc", "")), - priority=priority_value, - use_regex=bool(payload.get("use_regex", False)), - ) - return {} - - async def _system_session_waiter_register( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("system.session_waiter.register") - session_key = str(payload.get("session_key", "")).strip() - if not session_key: - raise AstrBotError.invalid_input( - "system.session_waiter.register requires session_key" - ) - self._session_waiters.setdefault(plugin_id, set()).add(session_key) - return {} - - async def _system_session_waiter_unregister( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - plugin_id = self._require_caller_plugin_id("system.session_waiter.unregister") - session_key = str(payload.get("session_key", "")).strip() - plugin_waiters = self._session_waiters.get(plugin_id) - if plugin_waiters is None: - return {} - plugin_waiters.discard(session_key) - if not plugin_waiters: - self._session_waiters.pop(plugin_id, None) - return {} - - async def _system_event_react( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self.event_actions.append( - { - "action": "react", - "emoji": str(payload.get("emoji", "")), - "target": _clone_target_payload(payload.get("target")), - } - ) - return {"supported": True} - - async def _system_event_send_typing( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - self.event_actions.append( - { - "action": "send_typing", - "target": _clone_target_payload(payload.get("target")), - } - ) - return {"supported": True} - - async def _system_event_send_streaming( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - stream_id = f"mock-stream-{len(self._event_streams) + 1}" - stream_state: dict[str, Any] = { - "target": _clone_target_payload(payload.get("target")), - "chunks": [], - "use_fallback": bool(payload.get("use_fallback", False)), - } - self._event_streams[stream_id] = stream_state - return {"supported": True, "stream_id": stream_id} - - async def _system_event_send_streaming_chunk( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - stream = self._event_streams.get(str(payload.get("stream_id", ""))) - if stream is None: - raise AstrBotError.invalid_input("Unknown sdk event streaming session") - chain = payload.get("chain") - if not isinstance(chain, list): - raise AstrBotError.invalid_input( - "system.event.send_streaming_chunk requires a chain array" - ) - stream["chunks"].append({"chain": _clone_chain_payload(chain)}) - return {} - - async def _system_event_send_streaming_close( - self, _request_id: str, payload: dict[str, Any], _token - ) -> dict[str, Any]: - stream_id = str(payload.get("stream_id", "")) - stream = self._event_streams.pop(stream_id, None) - if stream is None: - raise AstrBotError.invalid_input("Unknown sdk event streaming session") - self.event_actions.append( - { - "action": "send_streaming", - "target": stream["target"], - "chunks": list(stream["chunks"]), - "use_fallback": bool(stream["use_fallback"]), - } - ) - return {"supported": True} diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_command_matching.py b/astrbot-sdk/src/astrbot_sdk/runtime/_command_matching.py deleted file mode 100644 index 66dfa44f9..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_command_matching.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import re -import shlex -from collections.abc import Sequence -from typing import Any - -from ..protocol.descriptors import ParamSpec - - -def match_command_name(text: str, command_name: str) -> str | None: - normalized = text.strip() - if normalized == command_name: - return "" - if normalized.startswith(f"{command_name} "): - return normalized[len(command_name) :].strip() - return None - - -def build_command_args( - param_specs: Sequence[ParamSpec], remainder: str -) -> dict[str, Any]: - if not param_specs or not remainder: - return {} - if len(param_specs) == 1: - return {param_specs[0].name: remainder} - parts = split_command_remainder(remainder) - values: dict[str, Any] = {} - for index, spec in enumerate(param_specs): - if index >= len(parts): - break - if spec.type == "greedy_str": - values[spec.name] = " ".join(parts[index:]) - break - values[spec.name] = parts[index] - return values - - -def build_regex_args( - param_specs: Sequence[ParamSpec], match: re.Match[str] -) -> dict[str, Any]: - named = { - key: value for key, value in match.groupdict().items() if value is not None - } - names = [spec.name for spec in param_specs if spec.name not in named] - positional = [value for value in match.groups() if value is not None] - for index, value in enumerate(positional): - if index >= len(names): - break - named[names[index]] = value - return named - - -def split_command_remainder(remainder: str) -> list[str]: - if not remainder: - return [] - try: - return shlex.split(remainder) - except ValueError: - return remainder.split() diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_loader_support.py b/astrbot-sdk/src/astrbot_sdk/runtime/_loader_support.py deleted file mode 100644 index 40d162d35..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_loader_support.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Support helpers for runtime loader reflection and signature validation. - -本模块提供运行时加载器所需的反射和签名验证工具函数,主要用于: -1. 解析 handler/capability 函数签名,提取参数类型信息 -2. 识别需要注入的框架对象(如 Context、MessageEvent、ScheduleContext) -3. 构建参数规格 (ParamSpec) 供协议层使用 -4. 验证 schedule handler 的签名合法性 - -关键函数: -- build_param_specs: 从 handler 签名构建参数规格列表 -- is_injected_parameter: 判断参数是否应由框架注入而非从命令行解析 -- validate_schedule_signature: 确保 schedule handler 只接受允许的注入参数 -""" - -from __future__ import annotations - -import inspect -import typing -from typing import Any, Literal, TypeAlias, cast - -from .._internal.injected_params import is_framework_injected_parameter -from .._internal.typing_utils import unwrap_optional -from ..decorators import get_capability_meta, get_handler_meta -from ..protocol.descriptors import ParamSpec -from ..types import GreedyStr - -ParamTypeName: TypeAlias = Literal[ - "str", "int", "float", "bool", "optional", "greedy_str" -] -OptionalInnerType: TypeAlias = Literal["str", "int", "float", "bool"] | None - - -def is_injected_parameter(annotation: Any, parameter_name: str) -> bool: - return is_framework_injected_parameter(parameter_name, annotation) - - -def param_type_name(annotation: Any) -> tuple[ParamTypeName, OptionalInnerType, bool]: - normalized, is_optional = unwrap_optional(annotation) - if normalized is GreedyStr: - return "greedy_str", None, False - if normalized in {int, float, bool, str}: - normalized_name = cast( - Literal["str", "int", "float", "bool"], normalized.__name__ - ) - if is_optional: - return "optional", normalized_name, False - return normalized_name, None, True - if is_optional: - return "optional", "str", False - return "str", None, True - - -def build_param_specs(handler: Any) -> list[ParamSpec]: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return [] - try: - type_hints = typing.get_type_hints(handler) - except Exception: - type_hints = {} - - specs: list[ParamSpec] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - annotation = type_hints.get(parameter.name) - if is_injected_parameter(annotation, parameter.name): - continue - param_type, inner_type, required = param_type_name(annotation) - if parameter.default is not inspect.Parameter.empty: - required = False - specs.append( - ParamSpec( - name=parameter.name, - type=param_type, - required=required, - inner_type=inner_type, - ) - ) - - greedy_indexes = [ - index for index, spec in enumerate(specs) if spec.type == "greedy_str" - ] - if greedy_indexes and greedy_indexes[-1] != len(specs) - 1: - greedy_spec = specs[greedy_indexes[-1]] - raise ValueError(f"参数 '{greedy_spec.name}' (GreedyStr) 必须是最后一个参数。") - return specs - - -def validate_schedule_signature(handler: Any) -> None: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return - allowed_names = {"ctx", "context", "sched", "schedule"} - invalid = [ - parameter.name - for parameter in signature.parameters.values() - if parameter.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - and parameter.name not in allowed_names - ] - if invalid: - raise ValueError( - "Schedule handler 只允许注入 ctx/context 和 sched/schedule 参数。" - ) - - -def resolve_handler_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: - try: - raw = inspect.getattr_static(instance, name) - except AttributeError: - return None - candidates = [raw] - wrapped = getattr(raw, "__func__", None) - if wrapped is not None: - candidates.append(wrapped) - for candidate in candidates: - meta = get_handler_meta(candidate) - if meta is not None and meta.trigger is not None: - return getattr(instance, name), meta - return None - - -def resolve_capability_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: - try: - raw = inspect.getattr_static(instance, name) - except AttributeError: - return None - candidates = [raw] - wrapped = getattr(raw, "__func__", None) - if wrapped is not None: - candidates.append(wrapped) - for candidate in candidates: - meta = get_capability_meta(candidate) - if meta is not None: - return getattr(instance, name), meta - return None - - -__all__ = [ - "build_param_specs", - "is_injected_parameter", - "param_type_name", - "resolve_capability_candidate", - "resolve_handler_candidate", - "unwrap_optional", - "validate_schedule_signature", -] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/_streaming.py b/astrbot-sdk/src/astrbot_sdk/runtime/_streaming.py deleted file mode 100644 index 29d2671ca..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/_streaming.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Shared stream execution primitives for runtime internals. - -本模块定义流式执行的通用数据结构 StreamExecution,用于: -1. 封装异步生成器迭代器,支持逐块返回数据 -2. 提供收集完成后的聚合回调 (finalize) -3. 控制是否需要在内存中累积所有分块 - -使用场景: -- LLM 流式对话返回逐字输出 -- DB watch 监听键值变更流 -- 任何需要分块返回而非一次性返回的能力调用 -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Callable -from dataclasses import dataclass -from typing import Any - - -@dataclass(slots=True) -class StreamExecution: - iterator: AsyncIterator[dict[str, Any]] - finalize: Callable[[list[dict[str, Any]]], dict[str, Any]] - collect_chunks: bool = True - - -__all__ = ["StreamExecution"] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/bootstrap.py b/astrbot-sdk/src/astrbot_sdk/runtime/bootstrap.py deleted file mode 100644 index a08208f91..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/bootstrap.py +++ /dev/null @@ -1,133 +0,0 @@ -"""启动引导入口。 - -对外提供三个顶层启动函数: - -- ``run_supervisor``: 启动 Supervisor 进程 -- ``run_plugin_worker``: 启动单插件或组 Worker 进程 -- ``run_websocket_server``: 以 WebSocket 方式启动 Worker - -运行时核心类分布在同目录的子模块: - -- ``runtime.supervisor``: ``SupervisorRuntime`` / ``WorkerSession`` -- ``runtime.worker``: ``PluginWorkerRuntime`` / ``GroupWorkerRuntime`` -""" - -from __future__ import annotations - -import asyncio -import sys -from pathlib import Path -from typing import IO - -from .loader import PluginEnvironmentManager -from .supervisor import ( - SupervisorRuntime, - WorkerSession, - _install_signal_handlers, - _prepare_stdio_transport, - _sdk_source_dir, - _wait_for_shutdown, -) -from .transport import StdioTransport, WebSocketServerTransport -from .worker import GroupWorkerRuntime, PluginWorkerRuntime - -__all__ = [ - "GroupWorkerRuntime", - "PluginWorkerRuntime", - "SupervisorRuntime", - "WorkerSession", - "_install_signal_handlers", - "_prepare_stdio_transport", - "_sdk_source_dir", - "_wait_for_shutdown", - "run_supervisor", - "run_plugin_worker", - "run_websocket_server", -] - - -async def run_supervisor( - *, - plugins_dir: Path = Path("plugins"), - stdin: IO[str] | None = None, - stdout: IO[str] | None = None, - env_manager: PluginEnvironmentManager | None = None, -) -> None: - transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport( - stdin, - stdout, - ) - transport = StdioTransport(stdin=transport_stdin, stdout=transport_stdout) - runtime = SupervisorRuntime( - transport=transport, - plugins_dir=plugins_dir, - env_manager=env_manager, - ) - - try: - await runtime.start() - stop_event = asyncio.Event() - _install_signal_handlers(stop_event) - await _wait_for_shutdown(runtime.peer, stop_event) - finally: - await runtime.stop() - if original_stdout is not None: - sys.stdout = original_stdout - - -async def run_plugin_worker( - *, - plugin_dir: Path | None = None, - group_metadata: Path | None = None, - stdin: IO[str] | None = None, - stdout: IO[str] | None = None, -) -> None: - if plugin_dir is None and group_metadata is None: - raise ValueError("plugin_dir or group_metadata is required") - if plugin_dir is not None and group_metadata is not None: - raise ValueError("plugin_dir and group_metadata are mutually exclusive") - - transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport( - stdin, - stdout, - ) - transport = StdioTransport(stdin=transport_stdin, stdout=transport_stdout) - if group_metadata is not None: - runtime = GroupWorkerRuntime( - group_metadata_path=group_metadata, - transport=transport, - ) - else: - # 前置互斥校验已保证单插件模式下 plugin_dir 一定存在;这里显式收窄, - # 避免把入口层的 Optional 继续传播到单插件运行时。 - assert plugin_dir is not None - runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) - try: - await runtime.start() - stop_event = asyncio.Event() - _install_signal_handlers(stop_event) - await _wait_for_shutdown(runtime.peer, stop_event) - finally: - await runtime.stop() - if original_stdout is not None: - sys.stdout = original_stdout - - -async def run_websocket_server( - *, - host: str = "127.0.0.1", - port: int = 8765, - path: str = "/", - plugin_dir: Path | None = None, -) -> None: - runtime = PluginWorkerRuntime( - plugin_dir=plugin_dir or Path.cwd(), - transport=WebSocketServerTransport(host=host, port=port, path=path), - ) - try: - await runtime.start() - stop_event = asyncio.Event() - _install_signal_handlers(stop_event) - await _wait_for_shutdown(runtime.peer, stop_event) - finally: - await runtime.stop() diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/capability_dispatcher.py b/astrbot-sdk/src/astrbot_sdk/runtime/capability_dispatcher.py deleted file mode 100644 index c1d503490..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/capability_dispatcher.py +++ /dev/null @@ -1,511 +0,0 @@ -"""Capability invocation dispatcher. - -本模块实现能力调用的分发器,负责: -1. 接收能力调用请求,定位对应的已注册能力 -2. 构建调用上下文 (Context),注入必要的依赖 -3. 支持同步和流式两种调用模式 -4. 管理活跃调用任务的生命周期和取消 - -参数注入策略: -按类型注入 Context / CancelToken / dict,或按参数名注入 -ctx / context / payload / input / data / cancel_token / token。 -若无法匹配则抛出详细的错误信息,帮助开发者定位问题。 -""" - -from __future__ import annotations - -import asyncio -import inspect -import json -import typing -from collections.abc import AsyncIterator, Sequence -from typing import Any, cast, get_type_hints - -from loguru import logger - -from .._internal.invocation_context import caller_plugin_scope -from .._internal.plugin_logger import PluginLogger -from .._internal.star_runtime import bind_star_runtime -from .._internal.typing_utils import unwrap_optional -from ..context import CancelToken, Context -from ..errors import AstrBotError -from ..events import MessageEvent -from ..star import Star -from ._streaming import StreamExecution -from .loader import LoadedCapability, LoadedLLMTool - - -class CapabilityDispatcher: - def __init__( - self, - *, - plugin_id: str, - peer, - capabilities: Sequence[LoadedCapability], - llm_tools: Sequence[LoadedLLMTool] | None = None, - ) -> None: - self._plugin_id = plugin_id - self._peer = peer - self._capabilities = {item.descriptor.name: item for item in capabilities} - self._llm_tools: dict[tuple[str, str], LoadedLLMTool] = {} - try: - setattr(peer, "_sdk_capability_dispatcher", self) - except AttributeError: - logger.warning( - f"Failed to attach _sdk_capability_dispatcher to peer {peer}, " - "dynamic LLM tool registration may not work" - ) - for item in llm_tools or []: - self._register_llm_tool(item, item.plugin_id or plugin_id) - self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {} - - def _register_llm_tool( - self, - loaded: LoadedLLMTool, - owner_plugin: str, - ) -> None: - self._llm_tools[(owner_plugin, loaded.spec.name)] = loaded - if loaded.spec.handler_ref and loaded.spec.handler_ref != loaded.spec.name: - self._llm_tools[(owner_plugin, loaded.spec.handler_ref)] = loaded - - def add_dynamic_llm_tool( - self, - *, - plugin_id: str, - spec, - callable_obj, - owner: Any | None = None, - ) -> None: - self.remove_llm_tool(plugin_id, spec.name) - loaded = LoadedLLMTool( - spec=spec.model_copy(deep=True), - callable=callable_obj, - owner=owner, - plugin_id=plugin_id, - ) - self._register_llm_tool(loaded, plugin_id) - - def remove_llm_tool(self, plugin_id: str, name: str) -> bool: - removed = False - for key, value in list(self._llm_tools.items()): - if key[0] != plugin_id: - continue - spec_name = str(getattr(value.spec, "name", "")).strip() - handler_ref = str(getattr(value.spec, "handler_ref", "") or "").strip() - if name not in {spec_name, handler_ref}: - continue - self._llm_tools.pop(key, None) - removed = True - return removed - - async def invoke( - self, - message, - cancel_token: CancelToken, - ) -> dict[str, Any] | StreamExecution: - if message.capability == "internal.llm_tool.execute": - return await self._invoke_registered_llm_tool(message, cancel_token) - - loaded = self._capabilities.get(message.capability) - if loaded is None: - raise LookupError(f"capability not found: {message.capability}") - - plugin_id = self._resolve_plugin_id(loaded) - ctx = Context( - peer=self._peer, - plugin_id=plugin_id, - request_id=message.id, - cancel_token=cancel_token, - ) - bound_logger = cast(PluginLogger, ctx.logger).bind( - plugin_id=plugin_id, - request_id=message.id, - capability=message.capability, - session_id=self._logger_session_id(dict(message.input)), - event_type=self._logger_event_type(dict(message.input)), - ) - ctx.logger = bound_logger - - with caller_plugin_scope(plugin_id): - task = asyncio.create_task( - self._run_capability( - loaded, - payload=dict(message.input), - ctx=ctx, - cancel_token=cancel_token, - stream=bool(message.stream), - ) - ) - self._active[message.id] = (task, cancel_token) - try: - return await task - finally: - self._active.pop(message.id, None) - - async def _invoke_registered_llm_tool( - self, - message, - cancel_token: CancelToken, - ) -> dict[str, Any]: - payload = dict(message.input) - plugin_id = str(payload.get("plugin_id") or self._plugin_id) - tool_name = str(payload.get("tool_name", "")) - handler_ref = str(payload.get("handler_ref") or tool_name) - loaded = self._llm_tools.get((plugin_id, handler_ref)) - if loaded is None: - loaded = self._llm_tools.get((plugin_id, tool_name)) - if loaded is None: - raise LookupError(f"llm tool not found: {plugin_id}:{tool_name}") - - event_payload = payload.get("event") - ctx = Context( - peer=self._peer, - plugin_id=plugin_id, - request_id=message.id, - cancel_token=cancel_token, - source_event_payload=event_payload - if isinstance(event_payload, dict) - else None, - ) - bound_logger = cast(PluginLogger, ctx.logger).bind( - plugin_id=plugin_id, - request_id=message.id, - capability="internal.llm_tool.execute", - session_id=self._logger_session_id(payload), - event_type=self._logger_event_type(payload), - ) - ctx.logger = bound_logger - event = MessageEvent.from_payload( - event_payload if isinstance(event_payload, dict) else {}, - context=ctx, - ) - self._bind_event_reply_handler(ctx, event) - tool_args = payload.get("tool_args") - normalized_args = dict(tool_args) if isinstance(tool_args, dict) else {} - - with caller_plugin_scope(plugin_id): - task = asyncio.create_task( - self._run_registered_llm_tool(loaded, event, ctx, normalized_args) - ) - self._active[message.id] = (task, cancel_token) - try: - return await task - finally: - self._active.pop(message.id, None) - - def _bind_event_reply_handler(self, ctx: Context, event: MessageEvent) -> None: - async def reply(text: str) -> None: - try: - await ctx.platform.send(event.session_ref or event.session_id, text) - except TypeError: - send = getattr(self._peer, "send", None) - if not callable(send): - raise - result = send(event.session_id, text) - if inspect.isawaitable(result): - await result - - event.bind_reply_handler(reply) - - async def _run_registered_llm_tool( - self, - loaded: LoadedLLMTool, - event: MessageEvent, - ctx: Context, - tool_args: dict[str, Any], - ) -> dict[str, Any]: - owner = loaded.owner if isinstance(loaded.owner, Star) else None - with bind_star_runtime(owner, ctx): - result = loaded.callable( - *self._build_tool_args( - loaded.callable, - event, - ctx, - tool_args, - ) - ) - if inspect.isasyncgen(result): - raise AstrBotError.protocol_error( - "SDK LLM tool must return awaitable result, async generator is unsupported" - ) - if inspect.isawaitable(result): - result = await result - if result is None: - # content=None means the tool completed successfully but produced no - # textual payload. The core bridge preserves this as a real None. - return {"content": None, "success": True} - if isinstance(result, dict): - return { - "content": json.dumps(result, ensure_ascii=False, default=str), - "success": True, - } - return {"content": str(result), "success": True} - - def _build_tool_args( - self, - handler, - event: MessageEvent, - ctx: Context, - tool_args: dict[str, Any], - ) -> list[Any]: - signature = inspect.signature(handler) - args: list[Any] = [] - type_hints: dict[str, Any] = {} - try: - type_hints = get_type_hints(handler) - except Exception: - type_hints = {} - - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - - injected = None - param_type = type_hints.get(parameter.name) - if param_type is not None: - injected = self._inject_tool_by_type(param_type, event, ctx) - if injected is None: - if parameter.name == "event": - injected = event - elif parameter.name in {"ctx", "context"}: - injected = ctx - elif parameter.name in tool_args: - injected = tool_args[parameter.name] - if injected is None: - if parameter.default is not parameter.empty: - continue - raise TypeError( - f"SDK LLM tool '{getattr(handler, '__name__', repr(handler))}' missing required argument '{parameter.name}'" - ) - args.append(injected) - return args - - def _inject_tool_by_type( - self, - param_type: Any, - event: MessageEvent, - ctx: Context, - ) -> Any: - param_type, _is_optional = unwrap_optional(param_type) - - if param_type is Context or ( - isinstance(param_type, type) and issubclass(param_type, Context) - ): - return ctx - if param_type is MessageEvent or ( - isinstance(param_type, type) and issubclass(param_type, MessageEvent) - ): - return event - return None - - def _resolve_plugin_id(self, loaded: LoadedCapability) -> str: - if loaded.plugin_id: - return loaded.plugin_id - return self._plugin_id - - @staticmethod - def _logger_session_id(payload: dict[str, Any]) -> str: - if isinstance(payload.get("event"), dict): - return str(payload["event"].get("session_id", "")) - return str(payload.get("session", "")) - - @staticmethod - def _logger_event_type(payload: dict[str, Any]) -> str: - if isinstance(payload.get("event"), dict): - event_payload = payload["event"] - return str( - event_payload.get("event_type") - or event_payload.get("type") - or event_payload.get("message_type") - or "message" - ) - if payload.get("session") is not None: - return "capability" - return "capability" - - async def cancel(self, request_id: str) -> None: - active = self._active.get(request_id) - if active is None: - return - task, cancel_token = active - cancel_token.cancel() - task.cancel() - - async def _run_capability( - self, - loaded: LoadedCapability, - *, - payload: dict[str, Any], - ctx: Context, - cancel_token: CancelToken, - stream: bool, - ) -> dict[str, Any] | StreamExecution: - result = loaded.callable( - *self._build_args( - loaded.callable, - payload, - ctx, - cancel_token, - plugin_id=self._resolve_plugin_id(loaded), - capability_name=loaded.descriptor.name, - ) - ) - if stream: - if inspect.isasyncgen(result): - return StreamExecution( - iterator=self._iterate_generator(result), - finalize=lambda chunks: {"items": chunks}, - ) - if inspect.isawaitable(result): - result = await result - if isinstance(result, StreamExecution): - return result - raise AstrBotError.protocol_error( - "stream=true 的插件 capability 必须返回 async generator 或 StreamExecution" - ) - - if inspect.isasyncgen(result): - raise AstrBotError.protocol_error( - "stream=false 的插件 capability 不能返回 async generator" - ) - if inspect.isawaitable(result): - result = await result - return self._normalize_output(result) - - def _build_args( - self, - handler, - payload: dict[str, Any], - ctx: Context, - cancel_token: CancelToken, - *, - plugin_id: str | None = None, - capability_name: str | None = None, - ) -> list[Any]: - signature = inspect.signature(handler) - args: list[Any] = [] - - type_hints: dict[str, Any] = {} - try: - type_hints = get_type_hints(handler) - except Exception: - pass - - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - - injected = None - param_type = type_hints.get(parameter.name) - if param_type is not None: - injected = self._inject_by_type(param_type, payload, ctx, cancel_token) - - if injected is None: - if parameter.name in {"ctx", "context"}: - injected = ctx - elif parameter.name in {"payload", "input", "data"}: - injected = payload - elif parameter.name in {"cancel_token", "token"}: - injected = cancel_token - - if injected is None: - if parameter.default is not parameter.empty: - continue - raise TypeError( - self._format_capability_injection_error( - handler=handler, - parameter_name=parameter.name, - plugin_id=plugin_id, - capability_name=capability_name, - payload=payload, - ) - ) - args.append(injected) - - return args - - def _inject_by_type( - self, - param_type: Any, - payload: dict[str, Any], - ctx: Context, - cancel_token: CancelToken, - ) -> Any: - param_type, _is_optional = unwrap_optional(param_type) - origin = typing.get_origin(param_type) - - if param_type is Context or ( - isinstance(param_type, type) and issubclass(param_type, Context) - ): - return ctx - if param_type is CancelToken or ( - isinstance(param_type, type) and issubclass(param_type, CancelToken) - ): - return cancel_token - if param_type is dict or origin is dict: - return payload - return None - - def _format_capability_injection_error( - self, - *, - handler, - parameter_name: str, - plugin_id: str | None, - capability_name: str | None, - payload: dict[str, Any], - ) -> str: - plugin_text = plugin_id or self._plugin_id - target = capability_name or getattr(handler, "__name__", "") - payload_keys = sorted(str(key) for key in payload.keys()) - payload_keys_text = ", ".join(payload_keys) if payload_keys else "" - return ( - f"插件 '{plugin_text}' 的 capability '{target}' 参数注入失败:" - f"必填参数 '{parameter_name}' 无法注入。" - f"签名: {getattr(handler, '__name__', '')}" - f"{self._callable_signature(handler)}。" - "当前支持按类型注入 Context / CancelToken / dict," - "按参数名注入 ctx / context / payload / input / data / cancel_token / token," - f"以及 payload 中现有键:{payload_keys_text}。" - ) - - async def _iterate_generator( - self, - generator: AsyncIterator[Any], - ) -> AsyncIterator[dict[str, Any]]: - async for item in generator: - yield self._normalize_chunk(item) - - def _normalize_chunk(self, item: Any) -> dict[str, Any]: - output = self._normalize_output(item) - if output: - return output - return {"ok": True} - - def _normalize_output(self, result: Any) -> dict[str, Any]: - if result is None: - return {} - if isinstance(result, dict): - return result - model_dump = getattr(result, "model_dump", None) - if callable(model_dump): - dumped = model_dump() - if isinstance(dumped, dict): - return dumped - raise AstrBotError.invalid_input("插件 capability 必须返回 dict 或可序列化对象") - - @staticmethod - def _callable_signature(handler) -> str: - try: - return str(inspect.signature(handler)) - except (TypeError, ValueError): - return "(?)" - - -__all__ = ["CapabilityDispatcher"] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/capability_router.py b/astrbot-sdk/src/astrbot_sdk/runtime/capability_router.py deleted file mode 100644 index 93e820059..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/capability_router.py +++ /dev/null @@ -1,960 +0,0 @@ -"""能力路由模块。 - -定义 CapabilityRouter 类,负责能力的注册、发现和执行路由。 -能力是核心侧提供给插件侧调用的功能,如 LLM 聊天、存储、消息发送等。 - -核心概念: - CapabilityDescriptor: 能力描述符,声明能力名称、输入输出 Schema 等 - CallHandler: 同步调用处理器,签名 (request_id, payload, cancel_token) -> dict - StreamHandler: 流式调用处理器,签名 (request_id, payload, cancel_token) -> AsyncIterator - FinalizeHandler: 流式结果聚合器,签名 (chunks) -> dict - -内置能力: - LLM: - llm.chat: 同步 LLM 聊天 - llm.chat_raw: 同步 LLM 聊天(完整响应) - llm.stream_chat: 流式 LLM 聊天 - Memory: - memory.search: 搜索记忆 - memory.save: 保存记忆 - memory.save_with_ttl: 保存带过期时间的记忆 - memory.get: 读取单条记忆 - memory.get_many: 批量获取多条记忆 - memory.delete: 删除记忆 - memory.delete_many: 批量删除多条记忆 - memory.stats: 获取记忆统计信息 - DB: - db.get: 读取 KV 存储 - db.set: 写入 KV 存储 - db.delete: 删除 KV 存储 - db.list: 列出 KV 键 - db.get_many: 批量读取多个 KV 键 - db.set_many: 批量写入多个 KV 键 - db.watch: 订阅 KV 变更事件 - Platform: - platform.send: 发送消息 - platform.send_image: 发送图片 - platform.send_chain: 发送消息链 - platform.send_by_session: 主动按会话发送消息链 - platform.get_group: 获取当前群信息 - platform.get_members: 获取群成员 - HTTP: - http.register_api: 注册 HTTP 路由到插件 capability - http.unregister_api: 注销 HTTP 路由 - http.list_apis: 查询已注册的 HTTP 路由 - Metadata: - metadata.get_plugin: 获取单个插件元数据 - metadata.list_plugins: 列出所有插件元数据 - metadata.get_plugin_config: 获取当前调用插件自己的配置 - Provider: - provider.get_using: 获取当前聊天 Provider - provider.get_current_chat_provider_id: 获取当前聊天 Provider ID - provider.list_all: 列出聊天 Providers - provider.list_all_tts: 列出 TTS Providers - provider.list_all_stt: 列出 STT Providers - provider.list_all_embedding: 列出 Embedding Providers - provider.list_all_rerank: 列出 Rerank Providers - provider.get_using_tts: 获取当前 TTS Provider - provider.get_using_stt: 获取当前 STT Provider - provider.get_by_id: 按 ID 获取 Provider - provider.stt.get_text: STT 转写 - provider.tts.get_audio: TTS 合成音频 - provider.tts.support_stream: 检查 TTS 原生流式支持 - provider.tts.get_audio_stream: 流式 TTS 音频输出 - provider.embedding.get_embedding: 获取单条向量 - provider.embedding.get_embeddings: 批量获取向量 - provider.embedding.get_dim: 获取向量维度 - provider.rerank.rerank: 文档重排序 - provider.manager.set: 设置当前 Provider - provider.manager.get_by_id: 按 ID 获取 Provider 管理记录 - provider.manager.get_merged_provider_config: 获取 Provider 合并配置 - provider.manager.load: 运行时加载 Provider - provider.manager.terminate: 终止已加载的 Provider - provider.manager.create: 创建 Provider - provider.manager.update: 更新 Provider - provider.manager.delete: 删除 Provider - provider.manager.get_insts: 列出已加载聊天 Provider - provider.manager.watch_changes: 订阅 Provider 变更(流式) - Platform Manager: - platform.manager.get_by_id: 按 ID 获取平台管理快照 - platform.manager.clear_errors: 清除平台错误 - platform.manager.get_stats: 获取平台统计信息 - LLM Tool: - llm_tool.manager.get: 获取 LLM 工具状态 - llm_tool.manager.activate: 激活 LLM 工具 - llm_tool.manager.deactivate: 停用 LLM 工具 - llm_tool.manager.add: 动态添加 LLM 工具 - llm_tool.manager.remove: 动态移除 LLM 工具 - Agent: - agent.tool_loop.run: 运行 tool loop - agent.registry.list: 列出 Agent 元数据 - agent.registry.get: 获取 Agent 元数据 - Registry: - registry.get_handlers_by_event_type: 按事件类型列出 handler 元数据 - registry.get_handler_by_full_name: 按 full name 查询 handler 元数据 - Session: - session.plugin.is_enabled: 获取会话级插件开关 - session.plugin.filter_handlers: 按会话过滤 handler 元数据 - session.service.is_llm_enabled: 获取会话级 LLM 开关 - session.service.set_llm_status: 写入会话级 LLM 开关 - session.service.is_tts_enabled: 获取会话级 TTS 开关 - session.service.set_tts_status: 写入会话级 TTS 开关 - Managers: - persona.get / persona.list / persona.create / persona.update / persona.delete - conversation.new / conversation.switch / conversation.delete - conversation.get / conversation.list / conversation.update - kb.list / kb.get / kb.create / kb.update / kb.delete / kb.retrieve - kb.document.upload / kb.document.list / kb.document.get - kb.document.delete / kb.document.refresh - System (内部使用): - system.get_data_dir: 获取插件数据目录 - system.text_to_image: 文本转图片 - system.html_render: 渲染 HTML 模板 - system.file.register: 注册文件令牌 - system.file.handle: 解析文件令牌 - system.session_waiter.register: 注册会话等待器 - system.session_waiter.unregister: 注销会话等待器 - system.event.react: 发送事件表情回应 - system.event.send_typing: 发送输入中状态 - system.event.send_streaming: 发送事件流式消息 - system.event.send_streaming_chunk: 推送事件流式消息分片 - system.dynamic_command.register: 注册动态命令路由 - system.dynamic_command.list: 列出动态命令路由 - system.dynamic_command.remove: 移除动态命令路由 - -能力命名规范: - - 格式: {namespace}.{action} 或 {namespace}.{sub_namespace}.{action} - - 内置能力命名空间: llm, memory, db, platform, http, metadata, provider, llm_tool, agent, registry - - 保留命名空间前缀: handler., system., internal. - -使用示例: - router = CapabilityRouter() - - # 注册同步能力 - router.register( - CapabilityDescriptor( - name="my_plugin.calculate", - description="执行计算", - input_schema={"type": "object", "properties": {"x": {"type": "number"}}}, - output_schema={"type": "object", "properties": {"result": {"type": "number"}}}, - ), - call_handler=my_calculate, - ) - - # 注册流式能力 - async def stream_data(request_id, payload, token): - for i in range(10): - yield {"index": i} - - router.register( - CapabilityDescriptor( - name="my_plugin.stream", - description="流式数据", - supports_stream=True, - cancelable=True, - ), - stream_handler=stream_data, - finalize=lambda chunks: {"count": len(chunks)}, - ) - - # 执行能力 - result = await router.execute("my_plugin.calculate", {"x": 42}, stream=False, ...) - stream_result = await router.execute("my_plugin.stream", {}, stream=True, ...) -""" - -from __future__ import annotations - -import asyncio -import inspect -import re -from collections.abc import AsyncIterator, Awaitable, Callable -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from .._internal.invocation_context import current_caller_plugin_id -from ..errors import AstrBotError -from ..protocol.descriptors import ( - RESERVED_CAPABILITY_PREFIXES, - CapabilityDescriptor, -) -from ._capability_router_builtins import BuiltinCapabilityRouterMixin -from ._streaming import StreamExecution - -CallHandler = Callable[[str, dict[str, Any], object], Awaitable[dict[str, Any]]] -FinalizeHandler = Callable[[list[dict[str, Any]]], dict[str, Any]] -CAPABILITY_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") - - -StreamHandler = Callable[ - [str, dict[str, Any], object], - AsyncIterator[dict[str, Any]] - | StreamExecution - | Awaitable[AsyncIterator[dict[str, Any]] | StreamExecution], -] - - -@dataclass(slots=True) -class _CapabilityRegistration: - descriptor: CapabilityDescriptor - call_handler: CallHandler | None = None - stream_handler: StreamHandler | None = None - finalize: FinalizeHandler | None = None - exposed: bool = True - - -@dataclass(slots=True) -class _RegisteredPlugin: - metadata: dict[str, Any] - config: dict[str, Any] - handlers: list[dict[str, Any]] - llm_tools: dict[str, dict[str, Any]] = field(default_factory=dict) - active_llm_tools: set[str] = field(default_factory=set) - local_mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) - agents: dict[str, dict[str, Any]] = field(default_factory=dict) - skills: dict[str, dict[str, str]] = field(default_factory=dict) - - -class CapabilityRouter(BuiltinCapabilityRouterMixin): - def __init__(self) -> None: - self._registrations: dict[str, _CapabilityRegistration] = {} - self.db_store: dict[str, Any] = {} - self.memory_store: dict[str, dict[str, Any]] = {} - self._memory_backends: dict[str, Any] = {} - self._memory_index: dict[str, dict[str, Any]] = {} - self._memory_dirty_keys: set[str] = set() - self._memory_expires_at: dict[str, datetime | None] = {} - self.sent_messages: list[dict[str, Any]] = [] - self.event_actions: list[dict[str, Any]] = [] - self._event_streams: dict[str, dict[str, Any]] = {} - self.http_api_store: list[dict[str, Any]] = [] - self._plugins: dict[str, _RegisteredPlugin] = {} - self._request_overlays: dict[str, dict[str, Any]] = {} - self._provider_catalog: dict[str, list[dict[str, Any]]] = { - "chat": [ - { - "id": "mock-chat-provider", - "model": "mock-chat-model", - "type": "mock", - "provider_type": "chat_completion", - } - ], - "tts": [ - { - "id": "mock-tts-provider", - "model": "mock-tts-model", - "type": "mock", - "provider_type": "text_to_speech", - } - ], - "stt": [ - { - "id": "mock-stt-provider", - "model": "mock-stt-model", - "type": "mock", - "provider_type": "speech_to_text", - } - ], - "embedding": [ - { - "id": "mock-embedding-provider", - "model": "mock-embedding-model", - "type": "mock", - "provider_type": "embedding", - } - ], - "rerank": [ - { - "id": "mock-rerank-provider", - "model": "mock-rerank-model", - "type": "mock", - "provider_type": "rerank", - } - ], - } - self._provider_configs: dict[str, dict[str, Any]] = { - str(item["id"]): {**item, "enable": True} - for providers in self._provider_catalog.values() - for item in providers - } - self._active_provider_ids: dict[str, str | None] = { - kind: providers[0]["id"] if providers else None - for kind, providers in self._provider_catalog.items() - } - self._provider_change_subscriptions: dict[ - str, asyncio.Queue[dict[str, Any]] - ] = {} - self._system_data_root = Path.cwd() / ".astrbot_sdk_testing" / "plugin_data" - self._session_waiters: dict[str, set[str]] = {} - self._db_watch_subscriptions: dict[ - str, tuple[str | None, asyncio.Queue[dict[str, Any]]] - ] = {} - self._session_plugin_configs: dict[str, dict[str, Any]] = {} - self._session_service_configs: dict[str, dict[str, Any]] = {} - self._dynamic_command_routes: dict[str, list[dict[str, Any]]] = {} - self._file_token_store: dict[str, str] = {} - self._persona_store: dict[str, dict[str, Any]] = {} - self._conversation_store: dict[str, dict[str, Any]] = {} - self._session_current_conversation_ids: dict[str, str] = {} - self._message_history_store: dict[str, list[dict[str, Any]]] = {} - self._message_history_next_id = 1 - self._mcp_session_store: dict[str, dict[str, Any]] = {} - self._mcp_global_servers: dict[str, dict[str, Any]] = {} - self._mcp_audit_logs: list[dict[str, str]] = [] - self._kb_store: dict[str, dict[str, Any]] = {} - self._kb_document_store: dict[str, dict[str, dict[str, Any]]] = {} - self._kb_document_content_store: dict[str, str] = {} - self._platform_instances: list[dict[str, Any]] = [ - { - "id": "mock-platform", - "name": "Mock Platform", - "type": "mock", - "status": "running", - } - ] - self._register_builtin_capabilities() - - def upsert_plugin( - self, - *, - metadata: dict[str, Any], - config: dict[str, Any] | None = None, - ) -> None: - name = str(metadata.get("name", "")).strip() - if not name: - raise ValueError("plugin metadata must include a non-empty name") - normalized_metadata = dict(metadata) - normalized_metadata.setdefault("display_name", name) - normalized_metadata.setdefault("description", "") - normalized_metadata.setdefault("author", "") - normalized_metadata.setdefault("version", "0.0.0") - normalized_metadata.setdefault("enabled", True) - normalized_metadata.setdefault("reserved", False) - normalized_metadata.setdefault("acknowledge_global_mcp_risk", False) - normalized_metadata.setdefault("support_platforms", []) - normalized_metadata.setdefault("astrbot_version", None) - local_mcp_servers = normalized_metadata.pop("local_mcp_servers", {}) - self._plugins[name] = _RegisteredPlugin( - metadata=normalized_metadata, - config=dict(config or {}), - handlers=[], - local_mcp_servers={ - str(server_name): dict(server_payload) - for server_name, server_payload in local_mcp_servers.items() - if str(server_name).strip() and isinstance(server_payload, dict) - } - if isinstance(local_mcp_servers, dict) - else {}, - ) - - def set_plugin_handlers( - self, - name: str, - handlers: list[dict[str, Any]], - ) -> None: - plugin = self._plugins.get(name) - if plugin is None: - return - plugin.handlers = [dict(item) for item in handlers] - valid_handlers = { - str(item.get("handler_full_name", "")).strip() - for item in plugin.handlers - if isinstance(item, dict) - } - if not valid_handlers: - self._dynamic_command_routes.pop(name, None) - return - routes = self._dynamic_command_routes.get(name) - if routes is None: - return - self._dynamic_command_routes[name] = [ - dict(item) - for item in routes - if str(item.get("handler_full_name", "")).strip() in valid_handlers - ] - if not self._dynamic_command_routes[name]: - self._dynamic_command_routes.pop(name, None) - - def set_plugin_enabled(self, name: str, enabled: bool) -> None: - plugin = self._plugins.get(name) - if plugin is None: - return - plugin.metadata["enabled"] = enabled - - def register_dynamic_command_route( - self, - *, - plugin_id: str, - command_name: str, - handler_full_name: str, - desc: str = "", - priority: int = 0, - use_regex: bool = False, - ) -> None: - command_text = str(command_name).strip() - if not command_text: - raise AstrBotError.invalid_input("command_name must not be empty") - handler_text = str(handler_full_name).strip() - if not handler_text: - raise AstrBotError.invalid_input("handler_full_name must not be empty") - plugin = self._plugins.get(plugin_id) - if plugin is None: - raise AstrBotError.invalid_input(f"Unknown plugin: {plugin_id}") - if not self._plugin_has_handler(plugin_id, handler_text): - raise AstrBotError.invalid_input( - "handler_full_name must belong to the caller plugin and exist" - ) - route = { - "plugin_name": plugin_id, - "command_name": command_text, - "handler_full_name": handler_text, - "desc": str(desc), - "priority": int(priority), - "use_regex": bool(use_regex), - } - routes = [ - item - for item in self._dynamic_command_routes.get(plugin_id, []) - if str(item.get("command_name", "")).strip() != command_text - or bool(item.get("use_regex", False)) != bool(use_regex) - ] - routes.append(route) - self._dynamic_command_routes[plugin_id] = routes - - def list_dynamic_command_routes(self, plugin_id: str) -> list[dict[str, Any]]: - return [dict(item) for item in self._dynamic_command_routes.get(plugin_id, [])] - - def remove_dynamic_command_routes_for_plugin(self, plugin_id: str) -> None: - self._dynamic_command_routes.pop(plugin_id, None) - - def set_platform_instances(self, instances: list[dict[str, Any]]) -> None: - normalized: list[dict[str, Any]] = [] - for item in instances: - if not isinstance(item, dict): - continue - platform_id = str(item.get("id", "")).strip() - platform_type = str(item.get("type", "")).strip() - if not platform_id or not platform_type: - continue - errors = item.get("errors") - last_error = item.get("last_error") - stats = item.get("stats") - meta = item.get("meta") - normalized.append( - { - "id": platform_id, - "name": str(item.get("name", platform_id)), - "type": platform_type, - "status": str(item.get("status", "unknown")), - "errors": [ - dict(error) for error in errors if isinstance(error, dict) - ] - if isinstance(errors, list) - else [], - "last_error": ( - dict(last_error) if isinstance(last_error, dict) else None - ), - "unified_webhook": bool(item.get("unified_webhook", False)), - "stats": dict(stats) if isinstance(stats, dict) else None, - "meta": dict(meta) if isinstance(meta, dict) else {}, - "started_at": item.get("started_at"), - } - ) - self._platform_instances = normalized - - def get_platform_instances(self) -> list[dict[str, Any]]: - return [dict(item) for item in self._platform_instances] - - def _plugin_has_handler(self, plugin_id: str, handler_full_name: str) -> bool: - plugin = self._plugins.get(plugin_id) - if plugin is None: - return False - handler_name = str(handler_full_name).strip() - if not handler_name: - return False - for handler in plugin.handlers: - if not isinstance(handler, dict): - continue - if str(handler.get("handler_full_name", "")).strip() == handler_name: - return True - return False - - def set_plugin_llm_tools( - self, - name: str, - tools: list[dict[str, Any]], - ) -> None: - plugin = self._plugins.get(name) - if plugin is None: - return - plugin.llm_tools = { - str(item.get("name", "")): dict(item) - for item in tools - if isinstance(item, dict) and str(item.get("name", "")).strip() - } - plugin.active_llm_tools = { - tool_name - for tool_name, item in plugin.llm_tools.items() - if bool(item.get("active", True)) - } - - def set_plugin_agents( - self, - name: str, - agents: list[dict[str, Any]], - ) -> None: - plugin = self._plugins.get(name) - if plugin is None: - return - plugin.agents = { - str(item.get("name", "")): dict(item) - for item in agents - if isinstance(item, dict) and str(item.get("name", "")).strip() - } - - def set_provider_catalog( - self, - kind: str, - providers: list[dict[str, Any]], - *, - active_id: str | None = None, - ) -> None: - self._provider_catalog[kind] = [ - dict(item) - for item in providers - if isinstance(item, dict) and str(item.get("id", "")).strip() - ] - for item in self._provider_catalog[kind]: - provider_id = str(item.get("id", "")).strip() - if not provider_id: - continue - self._provider_configs[provider_id] = {**item, "enable": True} - if active_id is not None: - self._active_provider_ids[kind] = active_id - else: - catalog = self._provider_catalog[kind] - self._active_provider_ids[kind] = catalog[0]["id"] if catalog else None - - def emit_provider_change( - self, - provider_id: str, - provider_type: str, - umo: str | None = None, - ) -> None: - event = { - "provider_id": str(provider_id), - "provider_type": str(provider_type), - "umo": str(umo) if umo is not None else None, - } - for queue in list(self._provider_change_subscriptions.values()): - queue.put_nowait(dict(event)) - - def record_platform_error( - self, - platform_id: str, - message: str, - *, - traceback: str | None = None, - ) -> None: - for item in self._platform_instances: - if str(item.get("id", "")) != str(platform_id): - continue - error = { - "message": str(message), - "timestamp": datetime.now(timezone.utc).isoformat(), - "traceback": str(traceback) if traceback is not None else None, - } - errors = item.setdefault("errors", []) - if isinstance(errors, list): - errors.append(error) - item["last_error"] = error - item["status"] = "error" - return - - def set_platform_stats(self, platform_id: str, stats: dict[str, Any]) -> None: - for item in self._platform_instances: - if str(item.get("id", "")) != str(platform_id): - continue - item["stats"] = dict(stats) - return - - def set_session_plugin_config( - self, - session_id: str, - *, - enabled_plugins: list[str] | None = None, - disabled_plugins: list[str] | None = None, - ) -> None: - config: dict[str, Any] = {} - if enabled_plugins is not None: - config["enabled_plugins"] = [str(item) for item in enabled_plugins] - if disabled_plugins is not None: - config["disabled_plugins"] = [str(item) for item in disabled_plugins] - self._session_plugin_configs[str(session_id)] = config - - def set_session_service_config( - self, - session_id: str, - *, - llm_enabled: bool | None = None, - tts_enabled: bool | None = None, - ) -> None: - config: dict[str, Any] = {} - if llm_enabled is not None: - config["llm_enabled"] = bool(llm_enabled) - if tts_enabled is not None: - config["tts_enabled"] = bool(tts_enabled) - self._session_service_configs[str(session_id)] = config - - def remove_http_apis_for_plugin(self, plugin_id: str) -> None: - self.http_api_store = [ - entry - for entry in self.http_api_store - if entry.get("plugin_id") != plugin_id - ] - - @staticmethod - def _require_caller_plugin_id(capability_name: str) -> str: - caller_plugin_id = current_caller_plugin_id() - if caller_plugin_id: - return caller_plugin_id - raise AstrBotError.invalid_input( - f"{capability_name} 只能在插件运行时上下文中调用" - ) - - def _emit_db_change(self, *, op: str, key: str, value: Any | None) -> None: - event = {"op": op, "key": key, "value": value} - for prefix, queue in list(self._db_watch_subscriptions.values()): - if prefix is not None and not key.startswith(prefix): - continue - queue.put_nowait(event) - - def descriptors(self) -> list[CapabilityDescriptor]: - return [entry.descriptor for entry in self._registrations.values()] - - def contains(self, name: str) -> bool: - return name in self._registrations - - def unregister(self, name: str) -> None: - self._registrations.pop(name, None) - - def register( - self, - descriptor: CapabilityDescriptor, - *, - call_handler: CallHandler | None = None, - stream_handler: StreamHandler | None = None, - finalize: FinalizeHandler | None = None, - exposed: bool = True, - ) -> None: - is_internal_reserved = not exposed and descriptor.name.startswith( - RESERVED_CAPABILITY_PREFIXES - ) - if ( - not CAPABILITY_NAME_PATTERN.fullmatch(descriptor.name) - and not is_internal_reserved - ): - raise ValueError( - f"capability 名称必须匹配 {{namespace}}.{{method}}:{descriptor.name}" - ) - if exposed and descriptor.name.startswith(RESERVED_CAPABILITY_PREFIXES): - raise ValueError( - f"保留 capability 命名空间仅供框架内部使用:{descriptor.name}" - ) - self._registrations[descriptor.name] = _CapabilityRegistration( - descriptor=descriptor, - call_handler=call_handler, - stream_handler=stream_handler, - finalize=finalize, - exposed=exposed, - ) - - async def execute( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool, - cancel_token, - request_id: str, - ) -> dict[str, Any] | StreamExecution: - registration = self._registrations.get(capability) - if registration is None: - raise AstrBotError.capability_not_found(capability) - - self._validate_schema_with_context( - capability=capability, - phase="输入", - schema=registration.descriptor.input_schema, - payload=payload, - ) - if stream: - if registration.stream_handler is None: - raise AstrBotError.invalid_input(f"{capability} 不支持 stream=true") - raw_execution = registration.stream_handler( - request_id, payload, cancel_token - ) - if inspect.isawaitable(raw_execution): - raw_execution = await raw_execution - if isinstance(raw_execution, StreamExecution): - return self._wrap_stream_execution( - registration.descriptor, - raw_execution, - ) - finalize = registration.finalize or (lambda chunks: {"items": chunks}) - return self._wrap_stream_execution( - registration.descriptor, - StreamExecution( - iterator=raw_execution, - finalize=finalize, - ), - ) - - if registration.call_handler is None: - raise AstrBotError.invalid_input( - f"{capability} 只能以 stream=true 调用,registration.call_handler 为 None" - ) - output = await registration.call_handler(request_id, payload, cancel_token) - self._validate_schema_with_context( - capability=capability, - phase="输出", - schema=registration.descriptor.output_schema, - payload=output, - ) - return output - - def _wrap_stream_execution( - self, - descriptor: CapabilityDescriptor, - execution: StreamExecution, - ) -> StreamExecution: - def validated_finalize(chunks: list[dict[str, Any]]) -> dict[str, Any]: - output = execution.finalize(chunks) - self._validate_schema_with_context( - capability=descriptor.name, - phase="输出", - schema=descriptor.output_schema, - payload=output, - ) - return output - - return StreamExecution( - iterator=execution.iterator, - finalize=validated_finalize, - collect_chunks=execution.collect_chunks, - ) - - # ------------------------------------------------------------------ - # Schema validation - # ------------------------------------------------------------------ - - def _validate_schema( - self, - schema: dict[str, Any] | None, - payload: Any, - ) -> None: - if not isinstance(schema, dict) or not schema: - return - self._validate_value(schema, payload, path="") - - def _validate_schema_with_context( - self, - *, - capability: str, - phase: str, - schema: dict[str, Any] | None, - payload: Any, - ) -> None: - try: - self._validate_schema(schema, payload) - except AstrBotError as exc: - if exc.code != "invalid_input": - raise - raise AstrBotError.invalid_input( - f"capability '{capability}' 的{phase}校验失败:{exc.message}", - hint=( - f"请检查 capability '{capability}' 的{phase.lower()}是否符合声明的 schema" - ), - ) from exc - - def _validate_value( - self, - schema: dict[str, Any], - value: Any, - *, - path: str, - ) -> None: - any_of = schema.get("anyOf") - if isinstance(any_of, list): - for candidate in any_of: - if not isinstance(candidate, dict): - continue - try: - self._validate_value(candidate, value, path=path) - return - except AstrBotError: - continue - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 不符合允许的 schema 约束," - f"实际收到 {self._value_type_name(value)}" - ) - - enum = schema.get("enum") - if isinstance(enum, list) and value not in enum: - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 {enum},实际收到 {value!r}" - ) - - schema_type = schema.get("type") - if schema_type == "object": - if not isinstance(value, dict): - if not path: - raise AstrBotError.invalid_input( - f"输入必须是 object,实际收到 {self._value_type_name(value)}" - ) - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 object," - f"实际收到 {self._value_type_name(value)}" - ) - properties = schema.get("properties", {}) - required_fields = schema.get("required", []) - for field_name in required_fields: - field_path = self._join_path(path, str(field_name)) - if field_name not in value: - raise AstrBotError.invalid_input(f"缺少必填字段:{field_path}") - field_schema = self._property_schema(properties, field_name) - if value[field_name] is None and not self._schema_allows_null( - field_schema - ): - raise AstrBotError.invalid_input(f"缺少必填字段:{field_path}") - self._validate_value( - field_schema, - value[field_name], - path=field_path, - ) - for field_name, field_value in value.items(): - field_schema = properties.get(field_name) - if isinstance(field_schema, dict): - self._validate_value( - field_schema, - field_value, - path=self._join_path(path, str(field_name)), - ) - return - - if schema_type == "array": - if not isinstance(value, list): - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 array," - f"实际收到 {self._value_type_name(value)}" - ) - item_schema = schema.get("items") - if isinstance(item_schema, dict): - for index, item in enumerate(value): - self._validate_value( - item_schema, - item, - path=self._index_path(path, index), - ) - return - - if schema_type == "string": - if not isinstance(value, str): - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 string," - f"实际收到 {self._value_type_name(value)}" - ) - return - - if schema_type == "integer": - if not isinstance(value, int) or isinstance(value, bool): - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 integer," - f"实际收到 {self._value_type_name(value)}" - ) - return - - if schema_type == "number": - if not isinstance(value, (int, float)) or isinstance(value, bool): - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 number," - f"实际收到 {self._value_type_name(value)}" - ) - return - - if schema_type == "boolean": - if not isinstance(value, bool): - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 boolean," - f"实际收到 {self._value_type_name(value)}" - ) - return - - if schema_type == "null": - if value is not None: - raise AstrBotError.invalid_input( - f"{self._field_label(path)} 必须是 null," - f"实际收到 {self._value_type_name(value)}" - ) - return - - @staticmethod - def _field_label(path: str) -> str: - if not path: - return "输入" - return f"字段 {path}" - - @staticmethod - def _join_path(path: str, field_name: str) -> str: - if not path: - return field_name - return f"{path}.{field_name}" - - @staticmethod - def _index_path(path: str, index: int) -> str: - return f"{path}[{index}]" if path else f"[{index}]" - - @staticmethod - def _property_schema( - properties: Any, - field_name: str, - ) -> dict[str, Any]: - if not isinstance(properties, dict): - return {} - field_schema = properties.get(field_name) - if isinstance(field_schema, dict): - return field_schema - return {} - - @staticmethod - def _schema_allows_null(field_schema: Any) -> bool: - if not isinstance(field_schema, dict): - return False - if field_schema.get("type") == "null": - return True - any_of = field_schema.get("anyOf") - if not isinstance(any_of, list): - return False - return any( - isinstance(candidate, dict) and candidate.get("type") == "null" - for candidate in any_of - ) - - @staticmethod - def _value_type_name(value: Any) -> str: - if value is None: - return "null" - if isinstance(value, bool): - return "boolean" - if isinstance(value, int): - return "integer" - if isinstance(value, float): - return "number" - if isinstance(value, str): - return "string" - if isinstance(value, list): - return "array" - if isinstance(value, dict): - return "object" - return type(value).__name__ diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/environment_groups.py b/astrbot-sdk/src/astrbot_sdk/runtime/environment_groups.py deleted file mode 100644 index 982aaa297..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/environment_groups.py +++ /dev/null @@ -1,675 +0,0 @@ -"""v4 runtime 的插件共享环境规划模块。 - -这个模块负责“多个插件,共享较少数量 Python 环境”的策略。核心约束是: - -- 插件仍然独立发现、独立加载 -- Worker 进程仍然保持一插件一进程 -- 只有在依赖兼容时才共享 Python 环境 - -整体流程如下: - -1. 先按插件声明的 `runtime.python` 分桶 -2. 再按依赖兼容性构建候选分组 -3. 为每个分组在 `.astrbot/` 下落地 source、lock、metadata 和 venv 路径 -4. 在 worker 启动前准备或同步该分组的共享环境 - -当前阶段优先保证兼容性,因此仍保留 `--system-site-packages`,也不改变 -现有插件 manifest 语义。 -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import shutil -import subprocess -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .loader import PluginSpec - -GROUP_STATE_FILE_NAME = ".group-venv-state.json" - -_EXACT_PIN_PATTERN = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s;]+)$") -_NORMALIZE_PATTERN = re.compile(r"[-_.]+") -_PYVENV_VERSION_PATTERN = re.compile( - r"^(?:version|version_info)\s*=\s*(\d+\.\d+)(?:\.\d+)?\s*$", - re.IGNORECASE | re.MULTILINE, -) - - -def _require_uv_binary(uv_binary: str | None) -> str: - if not uv_binary: - raise RuntimeError("uv executable not found") - return uv_binary - - -def _venv_python_path(venv_path: Path) -> Path: - if os.name == "nt": - return venv_path / "Scripts" / "python.exe" - return venv_path / "bin" / "python" - - -def _normalize_package_name(name: str) -> str: - return _NORMALIZE_PATTERN.sub("-", name).lower() - - -def _read_pyvenv_major_minor(pyvenv_cfg: Path) -> str | None: - if not pyvenv_cfg.exists(): - return None - try: - content = pyvenv_cfg.read_text(encoding="utf-8") - except OSError: - return None - match = _PYVENV_VERSION_PATTERN.search(content) - if match is None: - return None - return match.group(1) - - -def _requirement_lines(plugin: PluginSpec) -> list[str]: - if not plugin.requirements_path.exists(): - return [] - - lines: list[str] = [] - for raw_line in plugin.requirements_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - lines.append(line) - return lines - - -@dataclass(slots=True) -class EnvironmentGroup: - """一个或多个兼容插件最终共享的环境描述。 - - 分组是环境复用的最小单位。`plugins` 中的所有插件都会使用同一个 - `python_path`、lockfile 和 venv 目录,但运行时仍然各自启动独立的 - worker 进程。 - """ - - id: str - python_version: str - plugins: list[PluginSpec] - source_path: Path - lockfile_path: Path - metadata_path: Path - venv_path: Path - python_path: Path - environment_fingerprint: str - - -@dataclass(slots=True) -class EnvironmentPlanResult: - """一次完整规划得到的结果。 - - `plugins` 只包含成功完成规划的插件。 - `skipped_plugins` 记录规划失败的插件及原因,这类插件即使单独成组也没 - 有得到可用的共享环境。 - """ - - groups: list[EnvironmentGroup] = field(default_factory=list) - plugins: list[PluginSpec] = field(default_factory=list) - plugin_to_group: dict[str, EnvironmentGroup] = field(default_factory=dict) - skipped_plugins: dict[str, str] = field(default_factory=dict) - - -class EnvironmentPlanner: - """负责共享环境规划和分组工件落地。 - - 对 supervisor 启动来说,这个类主要回答两个问题: - - - 哪些插件可以共享一个环境 - - 这个共享环境应该对应哪份 lockfile 和哪个 venv 路径 - - 它本身不负责真正创建或同步 venv,这部分在规划结束后交给 - `GroupEnvironmentManager` 处理。 - """ - - def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None: - self.repo_root = repo_root.resolve() - self.uv_binary = uv_binary or shutil.which("uv") - self.cache_dir = self.repo_root / ".uv-cache" - self.artifacts_dir = self.repo_root / ".astrbot" - self.group_dir = self.artifacts_dir / "groups" - self.lock_dir = self.artifacts_dir / "locks" - self.env_dir = self.artifacts_dir / "envs" - self._compatibility_cache: dict[str, bool] = {} - - def plan(self, plugins: list[PluginSpec]) -> EnvironmentPlanResult: - """为当前插件集合生成稳定的共享环境规划。 - - 之所以在 worker 启动前完成规划,是为了让 supervisor 能够: - - - 只跳过依赖无法满足的那部分插件 - - 在兼容插件之间复用同一个环境 - - 清理旧规划遗留的 `.astrbot` 工件 - """ - if not plugins: - self.cleanup_artifacts([]) - return EnvironmentPlanResult() - _require_uv_binary(self.uv_binary) - - candidate_groups = self._build_candidate_groups(plugins) - planned_groups: list[EnvironmentGroup] = [] - skipped_plugins: dict[str, str] = {} - for group_plugins in candidate_groups: - materialized, skipped = self._materialize_candidate_group(group_plugins) - planned_groups.extend(materialized) - skipped_plugins.update(skipped) - - planned_groups.sort(key=lambda group: (group.python_version, group.id)) - self.cleanup_artifacts(planned_groups) - - plugin_to_group = { - plugin.name: group for group in planned_groups for plugin in group.plugins - } - planned_plugins = [ - plugin for plugin in plugins if plugin.name in plugin_to_group - ] - return EnvironmentPlanResult( - groups=planned_groups, - plugins=planned_plugins, - plugin_to_group=plugin_to_group, - skipped_plugins=skipped_plugins, - ) - - def _build_candidate_groups( - self, plugins: list[PluginSpec] - ) -> list[list[PluginSpec]]: - """用贪心方式把插件装入兼容性候选组。 - - 分组过程保持确定性,规则是: - - - Python 版本是第一层硬边界 - - `requirements.txt` 约束更多的插件优先落位 - - 若仍相同,则按插件名排序 - """ - buckets: dict[str, list[PluginSpec]] = {} - for plugin in plugins: - buckets.setdefault(plugin.python_version, []).append(plugin) - - planned_groups: list[list[PluginSpec]] = [] - for python_version in sorted(buckets): - python_groups: list[list[PluginSpec]] = [] - for plugin in self._sort_plugins(buckets[python_version]): - placed = False - for group_plugins in python_groups: - if self._is_compatible([*group_plugins, plugin]): - group_plugins.append(plugin) - placed = True - break - if not placed: - python_groups.append([plugin]) - planned_groups.extend(python_groups) - return planned_groups - - @staticmethod - def _sort_plugins(plugins: list[PluginSpec]) -> list[PluginSpec]: - return sorted( - plugins, - key=lambda plugin: (-len(_requirement_lines(plugin)), plugin.name), - ) - - def _is_compatible(self, plugins: list[PluginSpec]) -> bool: - """判断一组插件是否可以共享一个环境。 - - 兼容性判断先走一个便宜的快速路径: - - - 如果每条 requirement 都是 `pkg==1.2.3` 这种精确版本锁定 - - 且归一化后的包名之间没有解析出冲突版本 - - 那么无需调用求解器,直接认为这一组兼容 - - 更复杂的情况则回退到 `uv pip compile`,以它的求解结果作为最终依 - 赖兼容性的判断依据。 - """ - cache_key = self._compatibility_cache_key(plugins) - cached = self._compatibility_cache.get(cache_key) - if cached is not None: - return cached - - requirement_lines = self._collect_requirement_lines(plugins) - if not requirement_lines: - self._compatibility_cache[cache_key] = True - return True - - if self._merge_exact_requirements(requirement_lines) is not None: - self._compatibility_cache[cache_key] = True - return True - - with tempfile.TemporaryDirectory( - prefix="astrbot-env-plan-", - dir=self.repo_root, - ) as temp_dir: - source_path = Path(temp_dir) / "compat.in" - output_path = Path(temp_dir) / "compat.txt" - self._write_source_file(source_path, plugins) - try: - self._compile_lockfile( - source_path=source_path, - output_path=output_path, - python_version=plugins[0].python_version, - ) - except RuntimeError: - self._compatibility_cache[cache_key] = False - return False - - self._compatibility_cache[cache_key] = True - return True - - def _materialize_candidate_group( - self, - plugins: list[PluginSpec], - ) -> tuple[list[EnvironmentGroup], dict[str, str]]: - """为一个候选组创建工件,失败时自动拆分。 - - 如果整组插件无法生成 lockfile,规划器会退回到“一插件一组”继续尝 - 试,避免单个坏插件阻塞整批插件启动。 - """ - try: - return [self._materialize_group(plugins)], {} - except RuntimeError as exc: - if len(plugins) == 1: - return [], {plugins[0].name: str(exc)} - - materialized: list[EnvironmentGroup] = [] - skipped: dict[str, str] = {} - for plugin in plugins: - groups, child_skipped = self._materialize_candidate_group([plugin]) - materialized.extend(groups) - skipped.update(child_skipped) - return materialized, skipped - - def _materialize_group(self, plugins: list[PluginSpec]) -> EnvironmentGroup: - """落地定义一个共享环境所需的全部文件。 - - 分组身份由 Python 版本和插件集合共同决定。 - 环境指纹则会进一步包含编译后的 lockfile 内容,这样当依赖解析结果 - 变化时,已有环境就可以走增量同步而不是盲目重建。 - """ - group_id = self._group_identity(plugins)[:16] - python_version = plugins[0].python_version - source_path = self.group_dir / f"{group_id}.in" - lockfile_path = self.lock_dir / f"{group_id}.txt" - metadata_path = self.group_dir / f"{group_id}.json" - venv_path = self.env_dir / group_id - python_path = _venv_python_path(venv_path) - - source_path.parent.mkdir(parents=True, exist_ok=True) - lockfile_path.parent.mkdir(parents=True, exist_ok=True) - metadata_path.parent.mkdir(parents=True, exist_ok=True) - venv_path.parent.mkdir(parents=True, exist_ok=True) - - self._write_source_file(source_path, plugins) - self._write_lockfile( - lockfile_path=lockfile_path, - source_path=source_path, - plugins=plugins, - python_version=python_version, - ) - environment_fingerprint = self._environment_fingerprint( - plugins=plugins, - python_version=python_version, - lockfile_path=lockfile_path, - ) - metadata_path.write_text( - json.dumps( - { - "group_id": group_id, - "python_version": python_version, - "plugins": [plugin.name for plugin in plugins], - "plugin_entries": [ - { - "name": plugin.name, - "plugin_dir": str(plugin.plugin_dir), - } - for plugin in plugins - ], - "source_path": str(source_path), - "lockfile_path": str(lockfile_path), - "venv_path": str(venv_path), - "environment_fingerprint": environment_fingerprint, - }, - ensure_ascii=True, - indent=2, - sort_keys=True, - ), - encoding="utf-8", - ) - - return EnvironmentGroup( - id=group_id, - python_version=python_version, - plugins=list(plugins), - source_path=source_path, - lockfile_path=lockfile_path, - metadata_path=metadata_path, - venv_path=venv_path, - python_path=python_path, - environment_fingerprint=environment_fingerprint, - ) - - def _write_source_file(self, source_path: Path, plugins: list[PluginSpec]) -> None: - """写入供 lockfile 生成使用的分组 requirements 输入文件。""" - lines: list[str] = [] - for plugin in sorted(plugins, key=lambda item: item.name): - requirements = _requirement_lines(plugin) - if not requirements: - continue - lines.append(f"# {plugin.name}") - lines.extend(requirements) - lines.append("") - - content = "\n".join(lines).rstrip() - if content: - content += "\n" - source_path.write_text(content, encoding="utf-8") - - def _write_lockfile( - self, - *, - lockfile_path: Path, - source_path: Path, - plugins: list[PluginSpec], - python_version: str, - ) -> None: - """为一个分组生成 lockfile。 - - 即使依赖集合为空,也会故意生成空 lockfile,这样整个共享环境流水 - 线的处理方式可以保持一致。 - """ - if not self._collect_requirement_lines(plugins): - lockfile_path.write_text("", encoding="utf-8") - return - - self._compile_lockfile( - source_path=source_path, - output_path=lockfile_path, - python_version=python_version, - ) - - def _compile_lockfile( - self, - *, - source_path: Path, - output_path: Path, - python_version: str, - ) -> None: - """把依赖求解委托给 `uv pip compile`。""" - uv_binary = _require_uv_binary(self.uv_binary) - self._run_command( - [ - uv_binary, - "pip", - "compile", - "--python-version", - python_version, - "--no-managed-python", - "--no-python-downloads", - "--quiet", - str(source_path), - "-o", - str(output_path), - ], - cwd=self.repo_root, - command_name=f"compile lockfile for {source_path.name}", - ) - - def _run_command(self, command: list[str], *, cwd: Path, command_name: str) -> None: - process = subprocess.run( - command, - cwd=str(cwd), - env={**os.environ, "UV_CACHE_DIR": str(self.cache_dir)}, - capture_output=True, - text=True, - check=False, - ) - if process.returncode != 0: - raise RuntimeError( - f"{command_name} failed with exit code {process.returncode}: " - f"{process.stderr.strip() or process.stdout.strip()}" - ) - - def cleanup_artifacts(self, groups: list[EnvironmentGroup]) -> None: - """清理不再被当前规划引用的 `.astrbot` 工件。 - - 清理范围只覆盖规划器自己维护的共享环境工件,不会碰旧式插件目录下 - 的本地 `.venv`。 - """ - active_group_ids = {group.id for group in groups} - self._cleanup_group_artifacts(active_group_ids) - self._cleanup_lockfiles(active_group_ids) - self._cleanup_envs(active_group_ids) - - def _cleanup_group_artifacts(self, active_group_ids: set[str]) -> None: - if not self.group_dir.exists(): - return - for entry in self.group_dir.iterdir(): - if entry.suffix not in {".in", ".json"}: - continue - if entry.stem in active_group_ids: - continue - entry.unlink(missing_ok=True) - - def _cleanup_lockfiles(self, active_group_ids: set[str]) -> None: - if not self.lock_dir.exists(): - return - for entry in self.lock_dir.iterdir(): - if entry.suffix != ".txt": - continue - if entry.stem in active_group_ids: - continue - entry.unlink(missing_ok=True) - - def _cleanup_envs(self, active_group_ids: set[str]) -> None: - if not self.env_dir.exists(): - return - for entry in self.env_dir.iterdir(): - if entry.name in active_group_ids: - continue - if entry.is_dir(): - shutil.rmtree(entry) - else: - entry.unlink(missing_ok=True) - - def _compatibility_cache_key(self, plugins: list[PluginSpec]) -> str: - payload = { - "python_version": plugins[0].python_version if plugins else "", - "plugins": [ - { - "name": plugin.name, - "requirements": _requirement_lines(plugin), - } - for plugin in sorted(plugins, key=lambda item: item.name) - ], - } - encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - @staticmethod - def _group_identity(plugins: list[PluginSpec]) -> str: - payload = { - "python_version": plugins[0].python_version if plugins else "", - "plugins": sorted(plugin.name for plugin in plugins), - } - encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - @staticmethod - def _environment_fingerprint( - *, - plugins: list[PluginSpec], - python_version: str, - lockfile_path: Path, - ) -> str: - payload = { - "python_version": python_version, - "plugins": sorted(plugin.name for plugin in plugins), - "lockfile": lockfile_path.read_text(encoding="utf-8"), - } - encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - @staticmethod - def _collect_requirement_lines(plugins: list[PluginSpec]) -> list[str]: - lines: list[str] = [] - for plugin in plugins: - lines.extend(_requirement_lines(plugin)) - return lines - - @staticmethod - def _merge_exact_requirements(requirement_lines: list[str]) -> list[str] | None: - merged: dict[str, str] = {} - for line in requirement_lines: - match = _EXACT_PIN_PATTERN.fullmatch(line) - if match is None: - return None - package_name = _normalize_package_name(match.group(1)) - existing = merged.get(package_name) - if existing is not None and existing != line: - return None - merged[package_name] = line - return [merged[name] for name in sorted(merged)] - - -class GroupEnvironmentManager: - """负责创建、校验和同步一个已经规划好的共享环境。""" - - def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None: - self.repo_root = repo_root.resolve() - self.uv_binary = uv_binary or shutil.which("uv") - self.cache_dir = self.repo_root / ".uv-cache" - - def prepare(self, group: EnvironmentGroup) -> Path: - """确保分组对应的解释器路径已经可以用于 worker 启动。 - - 行为概括如下: - - - 环境缺失、Python 版本不对、lockfile 丢失:重建 - - 环境结构还在但指纹变化:执行 `uv pip sync` - - 否则:直接复用现有解释器路径 - """ - _require_uv_binary(self.uv_binary) - - state_path = group.venv_path / GROUP_STATE_FILE_NAME - state = self._load_state(state_path) - if ( - not group.python_path.exists() - or not self._matches_python_version(group.venv_path, group.python_version) - or not group.lockfile_path.exists() - ): - self._rebuild(group) - self._write_state(state_path, group) - elif not self._state_matches_group(state, group): - self._sync_existing(group) - self._write_state(state_path, group) - return group.python_path - - def _rebuild(self, group: EnvironmentGroup) -> None: - if group.venv_path.exists(): - shutil.rmtree(group.venv_path) - self._create_venv(group) - self._sync_lockfile(group) - - def _sync_existing(self, group: EnvironmentGroup) -> None: - self._sync_lockfile(group) - - def _sync_lockfile(self, group: EnvironmentGroup) -> None: - """让已安装包与该分组的 lockfile 精确对齐。""" - uv_binary = _require_uv_binary(self.uv_binary) - self._run_command( - [ - uv_binary, - "pip", - "sync", - "--python", - str(group.python_path), - "--allow-empty-requirements", - str(group.lockfile_path), - ], - cwd=self.repo_root, - command_name=f"sync group env {group.id}", - ) - - def _create_venv(self, group: EnvironmentGroup) -> None: - """为一个分组创建共享 venv。 - - 当前迁移阶段仍保留 `--system-site-packages`,以兼容那些仍然隐式依 - 赖宿主环境包的旧插件。 - """ - uv_binary = _require_uv_binary(self.uv_binary) - self._run_command( - [ - uv_binary, - "venv", - "--python", - group.python_version, - "--system-site-packages", - "--no-python-downloads", - "--no-managed-python", - str(group.venv_path), - ], - cwd=self.repo_root, - command_name=f"create group venv {group.id}", - ) - - def _run_command(self, command: list[str], *, cwd: Path, command_name: str) -> None: - process = subprocess.run( - command, - cwd=str(cwd), - env={**os.environ, "UV_CACHE_DIR": str(self.cache_dir)}, - capture_output=True, - text=True, - check=False, - ) - if process.returncode != 0: - raise RuntimeError( - f"{command_name} failed with exit code {process.returncode}: " - f"{process.stderr.strip() or process.stdout.strip()}" - ) - - @staticmethod - def _matches_python_version(venv_path: Path, version: str) -> bool: - return _read_pyvenv_major_minor(venv_path / "pyvenv.cfg") == version - - @staticmethod - def _load_state(state_path: Path) -> dict[str, object]: - if not state_path.exists(): - return {} - try: - data = json.loads(state_path.read_text(encoding="utf-8")) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - @staticmethod - def _write_state(state_path: Path, group: EnvironmentGroup) -> None: - state_path.parent.mkdir(parents=True, exist_ok=True) - state_path.write_text( - json.dumps( - { - "group_id": group.id, - "python_version": group.python_version, - "environment_fingerprint": group.environment_fingerprint, - "plugins": [plugin.name for plugin in group.plugins], - }, - ensure_ascii=True, - indent=2, - sort_keys=True, - ), - encoding="utf-8", - ) - - @staticmethod - def _state_matches_group(state: dict[str, object], group: EnvironmentGroup) -> bool: - return ( - state.get("group_id") == group.id - and state.get("python_version") == group.python_version - and state.get("environment_fingerprint") == group.environment_fingerprint - ) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/handler_dispatcher.py b/astrbot-sdk/src/astrbot_sdk/runtime/handler_dispatcher.py deleted file mode 100644 index d1fcfcf8f..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/handler_dispatcher.py +++ /dev/null @@ -1,990 +0,0 @@ -"""处理器分发模块。 - -定义 HandlerDispatcher 类,负责将能力调用分发到具体的处理器函数。 -支持参数注入、流式执行、错误处理。 - -核心职责: - - 根据处理器 ID 查找处理器 - - 构建处理器参数(支持类型注解注入) - - 执行处理器并处理结果 - - 处理异步生成器流式结果 - - 统一的错误处理 - -参数注入优先级: - 1. 按类型注解注入(支持 Optional[Type]) - 2. 按参数名注入(兼容无类型注解) - 3. 从 args 注入(命令参数等) - -支持的注入类型: - - MessageEvent: 消息事件 - - Context: 运行时上下文 -""" - -from __future__ import annotations - -import asyncio -import inspect -import re -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, cast, get_type_hints - -from loguru import logger - -from .._internal.command_model import ( - parse_command_model_remainder, - resolve_command_model_param, -) -from .._internal.injected_params import legacy_arg_parameter_names -from .._internal.invocation_context import caller_plugin_scope -from .._internal.plugin_logger import PluginLogger -from .._internal.star_runtime import bind_star_runtime -from .._internal.typing_utils import unwrap_optional -from ..clients.llm import LLMResponse -from ..context import CancelToken, Context -from ..conversation import ( - DEFAULT_BUSY_MESSAGE, - ConversationClosed, - ConversationReplaced, - ConversationSession, - ConversationState, -) -from ..events import MessageEvent -from ..filters import LocalFilterBinding -from ..llm.entities import ProviderRequest -from ..message.components import BaseMessageComponent -from ..message.result import ( - MessageChain, - MessageEventResult, - coerce_message_chain, -) -from ..protocol.descriptors import ( - CommandTrigger, - MessageTrigger, - ParamSpec, - ScheduleTrigger, -) -from ..schedule import ScheduleContext -from ..session_waiter import ( - SessionWaiterManager, - _mark_session_waiter_handler_task, - _unmark_session_waiter_handler_task, -) -from ..star import Star -from ._command_matching import ( - build_command_args, - build_regex_args, - match_command_name, -) -from .capability_dispatcher import CapabilityDispatcher -from .limiter import LimiterEngine -from .loader import LoadedHandler - - -@dataclass(slots=True) -class _ActiveConversation: - session: ConversationSession - task: asyncio.Task[Any] - - -@dataclass(slots=True) -class _InjectedEventPayloads: - provider_request: ProviderRequest | None = None - llm_response: LLMResponse | None = None - event_result: MessageEventResult | None = None - - -class HandlerDispatcher: - def __init__( - self, *, plugin_id: str, peer, handlers: Sequence[LoadedHandler] - ) -> None: - self._plugin_id = plugin_id - self._peer = peer - self._handlers = {item.descriptor.id: item for item in handlers} - self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {} - self._session_waiters = SessionWaiterManager(plugin_id=plugin_id, peer=peer) - self._limiter = LimiterEngine() - self._conversations: dict[str, _ActiveConversation] = {} - try: - setattr(peer, "_session_waiter_manager", self._session_waiters) - except AttributeError: - logger.warning( - f"Failed to attach _session_waiter_manager to peer {peer}, " - "some features may not work as expected" - ) - - def has_active_waiter(self, event: MessageEvent) -> bool: - return self._session_waiters.has_active_waiter(event) - - async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]: - handler_id = str(message.input.get("handler_id", "")) - if handler_id == "__sdk_session_waiter__": - event_payload = message.input.get("event", {}) - requested_plugin_id = str(message.input.get("plugin_id") or "").strip() - ctx = Context( - peer=self._peer, - plugin_id=requested_plugin_id or self._plugin_id, - request_id=message.id, - cancel_token=cancel_token, - source_event_payload=event_payload - if isinstance(event_payload, dict) - else None, - ) - event = MessageEvent.from_payload(event_payload, context=ctx) - session_key = event.unified_msg_origin - if requested_plugin_id: - plugin_id = requested_plugin_id - else: - plugin_ids = self._session_waiters.get_waiter_plugin_ids(session_key) - if len(plugin_ids) > 1: - raise LookupError( - "multiple active session_waiters found for session; " - "dispatch requires explicit plugin identity" - ) - plugin_id = plugin_ids[0] if plugin_ids else self._plugin_id - if plugin_id != ctx.plugin_id: - ctx = Context( - peer=self._peer, - plugin_id=plugin_id, - request_id=message.id, - cancel_token=cancel_token, - source_event_payload=event_payload - if isinstance(event_payload, dict) - else None, - ) - event = MessageEvent.from_payload(event_payload, context=ctx) - event.bind_reply_handler(self._create_reply_handler(ctx, event)) - with caller_plugin_scope(plugin_id): - task = asyncio.create_task( - self._session_waiters.dispatch(event, plugin_id=plugin_id) - ) - _mark_session_waiter_handler_task(task) - task.add_done_callback(_unmark_session_waiter_handler_task) - self._active[message.id] = (task, cancel_token) - try: - return await task - finally: - self._active.pop(message.id, None) - - loaded = self._handlers.get(handler_id) - if loaded is None: - raise LookupError(f"handler not found: {handler_id}") - - plugin_id = self._resolve_plugin_id(loaded) - event_payload = message.input.get("event", {}) - ctx = Context( - peer=self._peer, - plugin_id=plugin_id, - request_id=message.id, - cancel_token=cancel_token, - source_event_payload=event_payload - if isinstance(event_payload, dict) - else None, - ) - event = MessageEvent.from_payload(event_payload, context=ctx) - bound_logger = cast(PluginLogger, ctx.logger).bind( - plugin_id=plugin_id, - request_id=message.id, - handler_ref=handler_id, - session_id=event.session_id, - event_type=str( - event_payload.get("event_type") - or event_payload.get("type") - or event.message_type - ), - ) - ctx.logger = bound_logger - event.bind_reply_handler(self._create_reply_handler(ctx, event)) - schedule_context = self._build_schedule_context(loaded, event_payload) - - # 提取 args 用于兼容 handler 签名 - raw_args = message.input.get("args") or {} - args = dict(raw_args) if isinstance(raw_args, dict) else {} - if not args: - args = self._derive_args(loaded, event) - - with caller_plugin_scope(plugin_id): - task = asyncio.create_task( - self._run_handler( - loaded, - event, - ctx, - args, - schedule_context=schedule_context, - ) - ) - _mark_session_waiter_handler_task(task) - task.add_done_callback(_unmark_session_waiter_handler_task) - self._active[message.id] = (task, cancel_token) - try: - return await task - finally: - self._active.pop(message.id, None) - - def _resolve_plugin_id(self, loaded: LoadedHandler) -> str: - if loaded.plugin_id: - return loaded.plugin_id - handler_id = getattr(loaded.descriptor, "id", "") - if isinstance(handler_id, str) and ":" in handler_id: - return handler_id.split(":", 1)[0] - return self._plugin_id - - def _create_reply_handler(self, ctx: Context, event: MessageEvent): - async def reply(text: str) -> None: - try: - await ctx.platform.send(event.session_ref or event.session_id, text) - except TypeError: - send = getattr(self._peer, "send", None) - if not callable(send): - raise - result = send(event.session_id, text) - if inspect.isawaitable(result): - await result - - return reply - - async def cancel(self, request_id: str) -> None: - active = self._active.get(request_id) - if active is None: - return - task, cancel_token = active - cancel_token.cancel() - task.cancel() - - async def _run_handler( - self, - loaded: LoadedHandler, - event: MessageEvent, - ctx: Context, - args: dict[str, Any] | None = None, - *, - schedule_context: ScheduleContext | None = None, - ) -> dict[str, Any]: - summary = {"sent_message": False, "stop": False, "call_llm": False} - injected_payloads = _InjectedEventPayloads() - event_type = self._event_type_name(event) - try: - limiter = loaded.limiter - if limiter is not None: - decision = self._limiter.evaluate( - plugin_id=self._resolve_plugin_id(loaded), - handler_id=loaded.descriptor.id, - limiter=limiter, - event=event, - ) - if not decision.allowed: - if decision.error is not None: - raise decision.error - if decision.hint: - await event.reply(decision.hint) - summary["sent_message"] = True - return summary - if not self._run_local_filters( - loaded.local_filters, - event=event, - ctx=ctx, - ): - return summary - parsed_args, help_text = self._prepare_handler_args( - loaded, - args or {}, - ) - if help_text is not None: - await event.reply(help_text) - summary["sent_message"] = True - return summary - if loaded.conversation is not None: - return await self._start_conversation( - loaded, - event, - ctx, - parsed_args, - schedule_context=schedule_context, - ) - owner = loaded.owner if isinstance(loaded.owner, Star) else None - with bind_star_runtime(owner, ctx): - result = loaded.callable( - *self._build_args( - loaded.callable, - event, - ctx, - parsed_args, - plugin_id=self._resolve_plugin_id(loaded), - handler_ref=loaded.descriptor.id, - schedule_context=schedule_context, - injected_payloads=injected_payloads, - ) - ) - if inspect.isasyncgen(result): - async for item in result: - self._merge_handler_summary( - summary, - await self._handle_result_item(item, event, ctx), - ) - summary["stop"] = bool(summary.get("stop")) or event.is_stopped() - self._append_injected_payloads( - summary, - injected_payloads, - event=event, - event_type=event_type, - ) - return summary - if inspect.isawaitable(result): - result = await result - if result is not None: - self._merge_handler_summary( - summary, - await self._handle_result_item(result, event, ctx), - ) - summary["stop"] = bool(summary.get("stop")) or event.is_stopped() - self._append_injected_payloads( - summary, - injected_payloads, - event=event, - event_type=event_type, - ) - return summary - except Exception as exc: - await self._handle_error( - loaded.owner, - exc, - event, - ctx, - handler_name=loaded.callable.__name__, - plugin_id=self._resolve_plugin_id(loaded), - ) - raise - - def _derive_args( - self, - loaded: LoadedHandler, - event: MessageEvent, - ) -> dict[str, Any]: - trigger = loaded.descriptor.trigger - if isinstance(trigger, CommandTrigger): - param_specs = loaded.descriptor.param_specs - for command_name in [trigger.command, *trigger.aliases]: - remainder = match_command_name(event.text, command_name) - if remainder is not None: - model_param = resolve_command_model_param(loaded.callable) - if model_param is not None: - return { - "__command_model_remainder__": remainder, - "__command_name__": command_name, - } - if param_specs: - return build_command_args(param_specs, remainder) - return build_command_args( - [ - ParamSpec(name=name, type="str") - for name in legacy_arg_parameter_names(loaded.callable) - ], - remainder, - ) - return {} - if isinstance(trigger, MessageTrigger) and trigger.regex: - match = re.search(trigger.regex, event.text) - if match is None: - return {} - if loaded.descriptor.param_specs: - return build_regex_args(loaded.descriptor.param_specs, match) - return build_regex_args( - [ - ParamSpec(name=name, type="str") - for name in legacy_arg_parameter_names(loaded.callable) - ], - match, - ) - return {} - - def _build_args( - self, - handler, - event: MessageEvent, - ctx: Context, - args: dict[str, Any] | None = None, - *, - plugin_id: str | None = None, - handler_ref: str | None = None, - schedule_context: ScheduleContext | None = None, - conversation_session: ConversationSession | None = None, - injected_payloads: _InjectedEventPayloads | None = None, - ) -> list[Any]: - """构建 handler 参数列表。""" - from loguru import logger - - signature = inspect.signature(handler) - injected_args: list[Any] = [] - args = args or {} - - type_hints: dict[str, Any] = {} - try: - type_hints = get_type_hints(handler) - except Exception: - pass - - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - - injected = None - - # 1. 优先按类型注解注入 - param_type = type_hints.get(parameter.name) - if param_type is not None: - injected = self._inject_by_type( - param_type, - event, - ctx, - schedule_context, - conversation_session, - injected_payloads=injected_payloads, - ) - - # 2. Fallback 按名字注入 - if injected is None: - if parameter.name == "event": - injected = event - elif parameter.name in {"ctx", "context"}: - injected = ctx - elif parameter.name in {"sched", "schedule"}: - injected = schedule_context - elif parameter.name in {"conversation", "conv"}: - injected = conversation_session - elif parameter.name in args: - injected = args[parameter.name] - - # 3. 检查是否有默认值 - if injected is None: - if parameter.default is not parameter.empty: - continue - logger.error( - "Handler '{}' 的必填参数 '{}' 无法注入", - handler.__name__, - parameter.name, - ) - raise TypeError( - self._format_handler_injection_error( - handler=handler, - parameter_name=parameter.name, - plugin_id=plugin_id, - handler_ref=handler_ref, - args=args, - ) - ) - else: - injected_args.append(injected) - - return injected_args - - def _prepare_handler_args( - self, - loaded: LoadedHandler, - args: dict[str, Any], - ) -> tuple[dict[str, Any], str | None]: - parsed_args = ( - self._parse_handler_args(loaded.descriptor.param_specs, args) - if loaded.descriptor.param_specs - else { - key: value - for key, value in dict(args).items() - if not str(key).startswith("__command_") - } - ) - if not isinstance(loaded.descriptor.trigger, CommandTrigger): - return parsed_args, None - model_param = resolve_command_model_param(loaded.callable) - if model_param is None: - return parsed_args, None - if "__command_model_remainder__" not in args: - return parsed_args, None - trigger = loaded.descriptor.trigger - command_name = str(args.get("__command_name__", "")) or ( - trigger.command - if isinstance(trigger, CommandTrigger) - else loaded.descriptor.id.rsplit(".", 1)[-1] - ) - result = parse_command_model_remainder( - remainder=str(args.get("__command_model_remainder__", "")), - model_param=model_param, - command_name=command_name, - ) - if result.help_text is not None: - return parsed_args, result.help_text - if result.model is not None: - parsed_args[model_param.name] = result.model - return parsed_args, None - - async def _start_conversation( - self, - loaded: LoadedHandler, - event: MessageEvent, - ctx: Context, - parsed_args: dict[str, Any], - *, - schedule_context: ScheduleContext | None, - ) -> dict[str, Any]: - assert loaded.conversation is not None - conversation_meta = loaded.conversation - summary = {"sent_message": False, "stop": True, "call_llm": False} - key = f"{self._resolve_plugin_id(loaded)}:{event.session_id}" - active = self._conversations.get(key) - if active is not None and not active.task.done(): - if conversation_meta.mode == "reject": - await event.reply( - conversation_meta.busy_message or DEFAULT_BUSY_MESSAGE - ) - summary["sent_message"] = True - return summary - active.session.mark_replaced() - await self._session_waiters.fail( - active.session.session_key, - ConversationReplaced("conversation replaced by a newer session"), - ) - await asyncio.sleep(0) - active.task.cancel() - try: - await asyncio.wait_for( - asyncio.shield(active.task), - timeout=conversation_meta.grace_period, - ) - except asyncio.TimeoutError: - cast(PluginLogger, ctx.logger).warning( - "Conversation replacement grace period exceeded for handler {}", - loaded.descriptor.id, - ) - except asyncio.CancelledError: - pass - except Exception: - pass - finally: - if self._conversations.get(key) is active: - self._conversations.pop(key, None) - - conversation = ConversationSession( - ctx=ctx, - event=event, - waiter_manager=self._session_waiters, - timeout=conversation_meta.timeout, - ) - - async def _runner() -> None: - try: - await self._run_conversation_task( - loaded, - event, - ctx, - parsed_args, - conversation, - schedule_context=schedule_context, - ) - finally: - if conversation.state == ConversationState.ACTIVE: - conversation.close(ConversationState.COMPLETED) - current = self._conversations.get(key) - if current is not None and current.session is conversation: - self._conversations.pop(key, None) - - task = await ctx.register_task( - _runner(), - f"conversation:{loaded.descriptor.id}", - ) - conversation.bind_owner_task(task) - self._conversations[key] = _ActiveConversation( - session=conversation, - task=task, - ) - return summary - - async def _run_conversation_task( - self, - loaded: LoadedHandler, - event: MessageEvent, - ctx: Context, - parsed_args: dict[str, Any], - conversation: ConversationSession, - *, - schedule_context: ScheduleContext | None, - ) -> None: - owner = loaded.owner if isinstance(loaded.owner, Star) else None - args_with_conversation = dict(parsed_args) - args_with_conversation.setdefault("conversation", conversation) - try: - with bind_star_runtime(owner, ctx): - result = loaded.callable( - *self._build_args( - loaded.callable, - event, - ctx, - args_with_conversation, - plugin_id=self._resolve_plugin_id(loaded), - handler_ref=loaded.descriptor.id, - schedule_context=schedule_context, - conversation_session=conversation, - ) - ) - if inspect.isasyncgen(result): - async for item in result: - await self._handle_result_item(item, event, ctx) - return - if inspect.isawaitable(result): - result = await result - if result is not None: - await self._handle_result_item(result, event, ctx) - except asyncio.CancelledError: - if conversation.state == ConversationState.ACTIVE: - conversation.close(ConversationState.CANCELLED) - raise - except (ConversationReplaced, ConversationClosed): - return - except Exception as exc: - await self._handle_error( - loaded.owner, - exc, - event, - ctx, - handler_name=loaded.callable.__name__, - plugin_id=self._resolve_plugin_id(loaded), - ) - - def _inject_by_type( - self, - param_type: Any, - event: MessageEvent, - ctx: Context, - schedule_context: ScheduleContext | None, - conversation_session: ConversationSession | None, - *, - injected_payloads: _InjectedEventPayloads | None = None, - ) -> Any: - """根据类型注解注入参数。""" - param_type, _is_optional = unwrap_optional(param_type) - - # 注入 MessageEvent 及其子类 - if param_type is MessageEvent: - return event - if isinstance(param_type, type) and issubclass(param_type, MessageEvent): - if isinstance(event, param_type): - return event - factory = getattr(param_type, "from_message_event", None) - if callable(factory): - return factory(event) - return event - - # 注入 Context 及其子类 - if param_type is Context or ( - isinstance(param_type, type) and issubclass(param_type, Context) - ): - return ctx - if param_type is ScheduleContext or ( - isinstance(param_type, type) and issubclass(param_type, ScheduleContext) - ): - return schedule_context - if param_type is ConversationSession or ( - isinstance(param_type, type) and issubclass(param_type, ConversationSession) - ): - return conversation_session - if param_type is ProviderRequest or ( - isinstance(param_type, type) and issubclass(param_type, ProviderRequest) - ): - return self._inject_provider_request(event, injected_payloads) - if param_type is LLMResponse or ( - isinstance(param_type, type) and issubclass(param_type, LLMResponse) - ): - return self._inject_llm_response(event, injected_payloads) - if param_type is MessageEventResult or ( - isinstance(param_type, type) and issubclass(param_type, MessageEventResult) - ): - return self._inject_event_result(event, injected_payloads) - - return None - - @staticmethod - def _event_type_name(event: MessageEvent) -> str: - raw = event.raw if isinstance(event.raw, dict) else {} - value = raw.get("event_type") or raw.get("type") - return str(value or "") - - @staticmethod - def _payload_from_event(event: MessageEvent, key: str) -> dict[str, Any] | None: - raw = event.raw if isinstance(event.raw, dict) else {} - payload = raw.get(key) - if isinstance(payload, dict): - return payload - nested_raw = raw.get("raw") - if isinstance(nested_raw, dict): - nested_payload = nested_raw.get(key) - if isinstance(nested_payload, dict): - return nested_payload - return None - - def _inject_provider_request( - self, - event: MessageEvent, - injected_payloads: _InjectedEventPayloads | None, - ) -> ProviderRequest | None: - if injected_payloads is None: - payload = self._payload_from_event(event, "provider_request") - return ( - ProviderRequest.from_payload(payload) if payload is not None else None - ) - if injected_payloads.provider_request is None: - payload = self._payload_from_event(event, "provider_request") - if payload is None: - return None - injected_payloads.provider_request = ProviderRequest.from_payload(payload) - return injected_payloads.provider_request - - def _inject_llm_response( - self, - event: MessageEvent, - injected_payloads: _InjectedEventPayloads | None, - ) -> LLMResponse | None: - if injected_payloads is None: - payload = self._payload_from_event(event, "llm_response") - return LLMResponse.model_validate(payload) if payload is not None else None - if injected_payloads.llm_response is None: - payload = self._payload_from_event(event, "llm_response") - if payload is None: - return None - injected_payloads.llm_response = LLMResponse.model_validate(payload) - return injected_payloads.llm_response - - def _inject_event_result( - self, - event: MessageEvent, - injected_payloads: _InjectedEventPayloads | None, - ) -> MessageEventResult | None: - if injected_payloads is None: - payload = self._payload_from_event(event, "event_result") - return ( - MessageEventResult.from_payload(payload) - if payload is not None - else None - ) - if injected_payloads.event_result is None: - payload = self._payload_from_event(event, "event_result") - if payload is None: - return None - injected_payloads.event_result = MessageEventResult.from_payload(payload) - return injected_payloads.event_result - - @staticmethod - def _append_injected_payloads( - summary: dict[str, Any], - injected_payloads: _InjectedEventPayloads, - *, - event: MessageEvent, - event_type: str, - ) -> None: - if ( - event_type == "llm_request" - and injected_payloads.provider_request is not None - ): - summary["provider_request"] = ( - injected_payloads.provider_request.to_payload() - ) - elif ( - event_type == "llm_response" and injected_payloads.llm_response is not None - ): - summary["llm_response"] = injected_payloads.llm_response.model_dump( - exclude_none=True - ) - elif ( - event_type == "decorating_result" - and injected_payloads.event_result is not None - ): - summary["event_result"] = injected_payloads.event_result.to_payload() - if event._should_serialize_sdk_local_extras(): # noqa: SLF001 - summary["sdk_local_extras"] = event._sdk_local_extras_payload() # noqa: SLF001 - - def _format_handler_injection_error( - self, - *, - handler, - parameter_name: str, - plugin_id: str | None, - handler_ref: str | None, - args: dict[str, Any], - ) -> str: - plugin_text = plugin_id or self._plugin_id - target = handler_ref or getattr(handler, "__name__", "") - arg_keys = sorted(str(key) for key in args.keys()) - arg_keys_text = ", ".join(arg_keys) if arg_keys else "" - return ( - f"插件 '{plugin_text}' 的 handler '{target}' 参数注入失败:" - f"必填参数 '{parameter_name}' 无法注入。" - f"签名: {getattr(handler, '__name__', '')}" - f"{self._callable_signature(handler)}。" - "当前支持按类型注入 MessageEvent / Context," - "按参数名注入 event / ctx / context," - f"以及 args 中现有键:{arg_keys_text}。" - ) - - @staticmethod - def _callable_signature(handler) -> str: - try: - return str(inspect.signature(handler)) - except (TypeError, ValueError): - return "(...)" - - async def _handle_result_item( - self, - item: Any, - event: MessageEvent, - ctx: Context | None = None, - ) -> dict[str, Any]: - sent_message = await self._send_result(item, event, ctx) - if isinstance(item, dict): - return { - "sent_message": sent_message, - "stop": bool(item.get("stop", False)), - "call_llm": bool(item.get("call_llm", False)), - } - return { - "sent_message": sent_message, - "stop": False, - "call_llm": False, - } - - @staticmethod - def _merge_handler_summary( - target: dict[str, Any], - source: dict[str, Any], - ) -> None: - target["sent_message"] = bool(target.get("sent_message")) or bool( - source.get("sent_message") - ) - target["stop"] = bool(target.get("stop")) or bool(source.get("stop")) - target["call_llm"] = bool(target.get("call_llm")) or bool( - source.get("call_llm") - ) - - async def _send_result( - self, - item: Any, - event: MessageEvent, - ctx: Context | None = None, - ) -> bool: - """发送处理器结果。""" - if isinstance(item, str): - await event.reply(item) - return True - if isinstance(item, dict) and "text" in item: - await event.reply(str(item["text"])) - return True - if isinstance(item, MessageEventResult): - chain = item.chain - if chain.components: - await event.reply_chain(chain) - return True - return False - chain = coerce_message_chain(item) - if chain is not None: - if chain.components: - await event.reply_chain(chain) - return True - return False - if isinstance(item, list) and all( - isinstance(component, BaseMessageComponent) for component in item - ): - await event.reply_chain(MessageChain(list(item))) - return True - # 支持带 text 属性的对象 - text = getattr(item, "text", None) - if isinstance(text, str): - await event.reply(text) - return True - return False - - @staticmethod - def _parse_handler_args( - param_specs: Sequence[ParamSpec], - args: dict[str, Any], - ) -> dict[str, Any]: - parsed: dict[str, Any] = {} - for spec in param_specs: - if spec.name not in args: - if spec.type == "optional": - parsed[spec.name] = None - continue - if spec.required: - raise TypeError(f"缺少参数: {spec.name}") - continue - parsed[spec.name] = HandlerDispatcher._convert_param(spec, args[spec.name]) - return parsed - - @staticmethod - def _convert_param(spec: ParamSpec, value: Any) -> Any: - if spec.type in {"str", "greedy_str"}: - return str(value) - if spec.type == "int": - return int(str(value)) - if spec.type == "float": - return float(str(value)) - if spec.type == "bool": - normalized = str(value).strip().lower() - if normalized in {"true", "1", "yes", "on"}: - return True - if normalized in {"false", "0", "no", "off"}: - return False - raise TypeError(f"无法解析布尔参数 {spec.name}: {value!r}") - if spec.type == "optional": - if value is None: - return None - inner = ParamSpec( - name=spec.name, - type=spec.inner_type or "str", - required=False, - ) - return HandlerDispatcher._convert_param(inner, value) - return value - - @staticmethod - def _run_local_filters( - bindings: list[LocalFilterBinding], - *, - event: MessageEvent, - ctx: Context, - ) -> bool: - for binding in bindings: - if not binding.evaluate(event=event, ctx=ctx): - return False - return True - - @staticmethod - def _build_schedule_context( - loaded: LoadedHandler, - event_payload: dict[str, Any], - ) -> ScheduleContext | None: - if not isinstance(loaded.descriptor.trigger, ScheduleTrigger): - return None - try: - return ScheduleContext.from_payload(event_payload) - except Exception: - return None - - async def _handle_error( - self, - owner: Any, - exc: Exception, - event: MessageEvent, - ctx: Context, - *, - handler_name: str = "", - plugin_id: str | None = None, - ) -> None: - if hasattr(owner, "on_error") and callable(owner.on_error): - bound_owner = owner if isinstance(owner, Star) else None - with bind_star_runtime(bound_owner, ctx): - result = owner.on_error(exc, event, ctx) - if inspect.isawaitable(result): - await result - return - await Star.default_on_error(exc, event, ctx) - - -__all__ = ["CapabilityDispatcher", "HandlerDispatcher"] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/limiter.py b/astrbot-sdk/src/astrbot_sdk/runtime/limiter.py deleted file mode 100644 index b32fe6e2d..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/limiter.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import time -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from ..decorators import LimiterMeta -from ..errors import AstrBotError - -DEFAULT_RATE_LIMIT_MESSAGE = "操作过于频繁,请稍后再试。" -DEFAULT_COOLDOWN_MESSAGE = "冷却中,请在 {remaining_seconds}s 后重试。" - - -@dataclass(slots=True) -class LimiterDecision: - allowed: bool - error: AstrBotError | None = None - hint: str | None = None - - -class LimiterEngine: - def __init__(self, *, clock: Callable[[], float] | None = None) -> None: - self._clock = clock or time.monotonic - self._windows: dict[str, deque[float]] = {} - - def evaluate( - self, - *, - plugin_id: str, - handler_id: str, - limiter: LimiterMeta, - event: Any, - ) -> LimiterDecision: - now = float(self._clock()) - key = self._make_key( - plugin_id=plugin_id, - handler_id=handler_id, - scope=limiter.scope, - event=event, - ) - bucket = self._windows.setdefault(key, deque()) - threshold = now - limiter.window - while bucket and bucket[0] <= threshold: - bucket.popleft() - - if len(bucket) < limiter.limit: - bucket.append(now) - return LimiterDecision(allowed=True) - - remaining = 0.0 - if bucket: - remaining = max(0.0, limiter.window - (now - bucket[0])) - hint = self._hint_text(limiter, remaining) - details = { - "scope": limiter.scope, - "handler_id": handler_id, - "remaining_seconds": round(remaining, 3), - } - if limiter.behavior == "silent": - return LimiterDecision(allowed=False) - if limiter.behavior == "error": - if limiter.kind == "cooldown": - return LimiterDecision( - allowed=False, - error=AstrBotError.cooldown_active(hint=hint, details=details), - ) - return LimiterDecision( - allowed=False, - error=AstrBotError.rate_limited(hint=hint, details=details), - ) - return LimiterDecision(allowed=False, hint=hint) - - @staticmethod - def _make_key( - *, - plugin_id: str, - handler_id: str, - scope: str, - event: Any, - ) -> str: - prefix = f"{plugin_id}:{handler_id}" - if scope == "global": - return prefix - if scope == "session": - return f"{prefix}:{getattr(event, 'session_id', '')}" - if scope == "user": - return ( - f"{prefix}:{getattr(event, 'platform_id', '')}" - f":{getattr(event, 'user_id', '')}" - ) - if scope == "group": - return ( - f"{prefix}:{getattr(event, 'platform_id', '')}" - f":{getattr(event, 'group_id', '')}" - ) - return prefix - - @staticmethod - def _hint_text(limiter: LimiterMeta, remaining: float) -> str: - if limiter.message: - return limiter.message.format( - remaining_seconds=max(1, int(remaining + 0.999)) - ) - if limiter.kind == "cooldown": - return DEFAULT_COOLDOWN_MESSAGE.format( - remaining_seconds=max(1, int(remaining + 0.999)) - ) - return DEFAULT_RATE_LIMIT_MESSAGE - - -__all__ = [ - "DEFAULT_COOLDOWN_MESSAGE", - "DEFAULT_RATE_LIMIT_MESSAGE", - "LimiterDecision", - "LimiterEngine", -] diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/loader.py b/astrbot-sdk/src/astrbot_sdk/runtime/loader.py deleted file mode 100644 index 822a6c13d..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/loader.py +++ /dev/null @@ -1,1106 +0,0 @@ -"""插件加载模块。 - -定义插件发现、环境管理和加载的核心逻辑。 -仅支持 v4 新版 Star 组件。 - -核心概念: - PluginSpec: 插件规范,描述插件的基本信息 - PluginDiscoveryResult: 插件发现结果,包含成功和跳过的插件 - PluginEnvironmentManager: 插件虚拟环境管理器 - LoadedHandler: 加载后的处理器,包含描述符和可调用对象 - LoadedPlugin: 加载后的插件,包含处理器和实例 - -插件发现流程: - 1. 扫描 plugins_dir 下的子目录 - 2. 检查 plugin.yaml 和 requirements.txt - 3. 解析 manifest_data 获取插件信息 - 4. 验证必要字段(name, components, runtime.python) - 5. 返回 PluginDiscoveryResult - -环境管理流程: - 1. 对插件集合做共享环境规划 - 2. 按 Python 版本和依赖兼容性构建环境分组 - 3. 为每个分组生成 lock/source/metadata 工件 - 4. 必要时重建或同步分组虚拟环境 - 5. 将单个插件映射到所属分组环境 - -插件加载流程: - 1. 将插件目录添加到 sys.path - 2. 遍历 components 列表 - 3. 动态导入组件类 - 4. 直接实例化(无参构造函数) - 5. 扫描处理器方法 - 6. 构建 HandlerDescriptor - -plugin.yaml 格式: - name: my_plugin - author: author_name - desc: Plugin description - version: 1.0.0 - runtime: - python: "3.11" - components: - - class: my_plugin.main:MyComponent - -`loader` 是 runtime 与插件代码之间的边界层,负责三件事: - -- 从 `plugin.yaml` 解析出可运行的 `PluginSpec` -- 用 `uv` 为插件准备独立环境 -- 把组件实例和 handler 元数据整理成 `LoadedPlugin` -""" - -from __future__ import annotations - -import copy -import importlib -import inspect -import json -import logging -import os -import re -import shutil -import sys -import typing -from dataclasses import dataclass, field -from importlib import import_module -from pathlib import Path -from typing import Any, Literal, TypeAlias, cast - -import yaml - -from .._internal.command_model import resolve_command_model_param -from .._internal.injected_params import is_framework_injected_parameter -from .._internal.plugin_ids import validate_plugin_id -from .._internal.typing_utils import unwrap_optional -from ..decorators import ( - ConversationMeta, - LimiterMeta, - get_agent_meta, - get_capability_meta, - get_handler_meta, - get_llm_tool_meta, -) -from ..llm.agents import AgentSpec -from ..llm.entities import LLMToolSpec -from ..protocol.descriptors import ( - CapabilityDescriptor, - HandlerDescriptor, - ParamSpec, - ScheduleTrigger, -) -from ..types import GreedyStr -from .environment_groups import ( - EnvironmentGroup, - EnvironmentPlanner, - EnvironmentPlanResult, - GroupEnvironmentManager, -) - -PLUGIN_MANIFEST_FILE = "plugin.yaml" -STATE_FILE_NAME = ".astrbot-worker-state.json" -CONFIG_SCHEMA_FILE = "_conf_schema.json" -PLUGIN_METADATA_ATTR = "__astrbot_plugin_metadata__" -ParamTypeName: TypeAlias = Literal[ - "str", "int", "float", "bool", "optional", "greedy_str" -] -OptionalInnerType: TypeAlias = Literal["str", "int", "float", "bool"] | None -HandlerKind: TypeAlias = Literal["handler", "hook", "tool", "session"] -DiscoverySeverity: TypeAlias = Literal["warning", "error"] -DiscoveryPhase: TypeAlias = Literal["discovery", "load", "lifecycle", "reload"] -_LOGGER = logging.getLogger(__name__) - - -def _default_python_version() -> str: - return f"{sys.version_info.major}.{sys.version_info.minor}" - - -def _venv_python_path(venv_dir: Path) -> Path: - if os.name == "nt": - return venv_dir / "Scripts" / "python.exe" - return venv_dir / "bin" / "python" - - -@dataclass(slots=True) -class PluginSpec: - name: str - plugin_dir: Path - manifest_path: Path - requirements_path: Path - python_version: str - manifest_data: dict[str, Any] - - -@dataclass(slots=True) -class PluginDiscoveryResult: - plugins: list[PluginSpec] - skipped_plugins: dict[str, str] - issues: list[PluginDiscoveryIssue] = field(default_factory=list) - - -@dataclass(slots=True) -class PluginDiscoveryIssue: - severity: DiscoverySeverity - phase: DiscoveryPhase - plugin_id: str - message: str - details: str = "" - hint: str = "" - - def to_payload(self) -> dict[str, str]: - return { - "severity": self.severity, - "phase": self.phase, - "plugin_id": self.plugin_id, - "message": self.message, - "details": self.details, - "hint": self.hint, - } - - -@dataclass(slots=True) -class LoadedHandler: - descriptor: HandlerDescriptor - callable: Any - owner: Any - plugin_id: str = "" - local_filters: list[Any] = field(default_factory=list) - limiter: LimiterMeta | None = None - conversation: ConversationMeta | None = None - - -@dataclass(slots=True) -class LoadedCapability: - descriptor: CapabilityDescriptor - callable: Any - owner: Any - plugin_id: str = "" - - -@dataclass(slots=True) -class LoadedLLMTool: - spec: LLMToolSpec - callable: Any - owner: Any - plugin_id: str = "" - - -@dataclass(slots=True) -class LoadedAgent: - spec: AgentSpec - runner_class: type[Any] - owner: Any | None = None - plugin_id: str = "" - - -@dataclass(slots=True) -class LoadedPlugin: - plugin: PluginSpec - handlers: list[LoadedHandler] - capabilities: list[LoadedCapability] = field(default_factory=list) - llm_tools: list[LoadedLLMTool] = field(default_factory=list) - agents: list[LoadedAgent] = field(default_factory=list) - instances: list[Any] = field(default_factory=list) - - -@dataclass(slots=True) -class _ResolvedComponent: - cls: type[Any] - class_path: str - index: int - - -def _iter_handler_names(instance: Any) -> list[str]: - handler_names = getattr(instance.__class__, "__handlers__", ()) - if handler_names: - return list(handler_names) - return list(dir(instance)) - - -def _iter_discoverable_names(instance: Any) -> list[str]: - handler_names = list(dict.fromkeys(_iter_handler_names(instance))) - known_names = set(handler_names) - extra_names = sorted(name for name in dir(instance) if name not in known_names) - return [*handler_names, *extra_names] - - -def _is_injected_parameter(annotation: Any, parameter_name: str) -> bool: - return is_framework_injected_parameter(parameter_name, annotation) - - -def _param_type_name(annotation: Any) -> tuple[ParamTypeName, OptionalInnerType, bool]: - normalized, is_optional = unwrap_optional(annotation) - if normalized is GreedyStr: - return "greedy_str", None, False - if normalized in {int, float, bool, str}: - normalized_name = cast( - Literal["str", "int", "float", "bool"], normalized.__name__ - ) - if is_optional: - return "optional", normalized_name, False - return normalized_name, None, True - if is_optional: - return "optional", "str", False - return "str", None, True - - -def _build_param_specs(handler: Any) -> list[ParamSpec]: - model_param = resolve_command_model_param(handler) - if model_param is not None: - return [] - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return [] - try: - type_hints = typing.get_type_hints(handler) - except Exception: - type_hints = {} - - specs: list[ParamSpec] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - annotation = type_hints.get(parameter.name) - if _is_injected_parameter(annotation, parameter.name): - continue - param_type, inner_type, required = _param_type_name(annotation) - if parameter.default is not inspect.Parameter.empty: - required = False - specs.append( - ParamSpec( - name=parameter.name, - type=param_type, - required=required, - inner_type=inner_type, - ) - ) - - greedy_indexes = [ - index for index, spec in enumerate(specs) if spec.type == "greedy_str" - ] - if greedy_indexes and greedy_indexes[-1] != len(specs) - 1: - greedy_spec = specs[greedy_indexes[-1]] - raise ValueError(f"参数 '{greedy_spec.name}' (GreedyStr) 必须是最后一个参数。") - return specs - - -def _validate_schedule_signature(handler: Any) -> None: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return - allowed_names = {"ctx", "context", "sched", "schedule"} - invalid = [ - parameter.name - for parameter in signature.parameters.values() - if parameter.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - and parameter.name not in allowed_names - ] - if invalid: - raise ValueError( - "Schedule handler 只允许注入 ctx/context 和 sched/schedule 参数。" - ) - - -def _plugin_context(plugin: PluginSpec) -> str: - return f"插件 '{plugin.name}'({plugin.manifest_path})" - - -def _component_context(plugin: PluginSpec, *, class_path: str, index: int) -> str: - return f"{_plugin_context(plugin)} 的 components[{index}].class='{class_path}'" - - -def _resolve_handler_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: - """解析 handler 名称,避免在扫描阶段触发无关 descriptor 副作用。""" - try: - raw = inspect.getattr_static(instance, name) - except AttributeError: - return None - - candidates = [raw] - wrapped = getattr(raw, "__func__", None) - if wrapped is not None: - candidates.append(wrapped) - - for candidate in candidates: - meta = get_handler_meta(candidate) - if meta is not None and meta.trigger is not None: - return getattr(instance, name), meta - return None - - -def _resolve_capability_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: - try: - raw = inspect.getattr_static(instance, name) - except AttributeError: - return None - - candidates = [raw] - wrapped = getattr(raw, "__func__", None) - if wrapped is not None: - candidates.append(wrapped) - - for candidate in candidates: - meta = get_capability_meta(candidate) - if meta is not None: - return getattr(instance, name), meta - return None - - -def _resolve_llm_tool_candidate(instance: Any, name: str) -> tuple[Any, Any] | None: - try: - raw = inspect.getattr_static(instance, name) - except AttributeError: - return None - - candidates = [raw] - wrapped = getattr(raw, "__func__", None) - if wrapped is not None: - candidates.append(wrapped) - - for candidate in candidates: - meta = get_llm_tool_meta(candidate) - if meta is not None: - return getattr(instance, name), meta - return None - - -def _iter_agent_candidates(component_cls: type[Any]) -> list[tuple[type[Any], Any]]: - module = import_module(component_cls.__module__) - seen: set[str] = set() - resolved: list[tuple[type[Any], Any]] = [] - - def _collect(candidate: Any) -> None: - if not inspect.isclass(candidate): - return - meta = get_agent_meta(candidate) - if meta is None: - return - key = f"{candidate.__module__}.{candidate.__qualname__}" - if key in seen: - return - seen.add(key) - resolved.append((candidate, meta)) - - for candidate in vars(module).values(): - _collect(candidate) - for candidate in vars(component_cls).values(): - _collect(candidate) - return resolved - - -def _read_yaml(path: Path) -> dict[str, Any]: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - return data if isinstance(data, dict) else {} - - -def _read_requirements_text(path: Path) -> str: - if not path.exists(): - return "" - return path.read_text(encoding="utf-8") - - -def _plugin_config_dir(plugin_dir: Path) -> Path: - if plugin_dir.parent.name == "plugins" and plugin_dir.parent.parent.exists(): - return plugin_dir.parent.parent / "config" - return plugin_dir / "data" / "config" - - -def _plugin_config_path(plugin_dir: Path, plugin_name: str) -> Path: - return _plugin_config_dir(plugin_dir) / f"{plugin_name}_config.json" - - -def _schema_default(field_schema: dict[str, Any]) -> Any: - if "default" in field_schema: - return copy.deepcopy(field_schema["default"]) - - field_type = str(field_schema.get("type") or "string") - if field_type == "object": - items = field_schema.get("items") - if isinstance(items, dict): - return { - key: _normalize_config_value(child_schema, None) - for key, child_schema in items.items() - if isinstance(child_schema, dict) - } - return {} - if field_type in {"list", "template_list", "file"}: - return [] - if field_type == "dict": - return {} - if field_type == "int": - return 0 - if field_type == "float": - return 0.0 - if field_type == "bool": - return False - return "" - - -def _normalize_config_value(field_schema: dict[str, Any], value: Any) -> Any: - field_type = str(field_schema.get("type") or "string") - default_value = _schema_default(field_schema) - - if field_type == "object": - items = field_schema.get("items") - if not isinstance(items, dict): - return default_value - current = value if isinstance(value, dict) else {} - return { - key: _normalize_config_value(child_schema, current.get(key)) - for key, child_schema in items.items() - if isinstance(child_schema, dict) - } - if field_type in {"list", "template_list", "file"}: - return copy.deepcopy(value) if isinstance(value, list) else default_value - if field_type == "dict": - return copy.deepcopy(value) if isinstance(value, dict) else default_value - if field_type == "int": - return ( - value - if isinstance(value, int) and not isinstance(value, bool) - else default_value - ) - if field_type == "float": - return ( - value - if isinstance(value, (int, float)) and not isinstance(value, bool) - else default_value - ) - if field_type == "bool": - return value if isinstance(value, bool) else default_value - if field_type in {"string", "text"}: - return value if isinstance(value, str) else default_value - return copy.deepcopy(value) if value is not None else default_value - - -def load_plugin_config_schema(plugin: PluginSpec) -> dict[str, Any]: - """加载插件配置 schema,解析失败时记录日志并返回空对象。""" - schema_path = plugin.plugin_dir / CONFIG_SCHEMA_FILE - if not schema_path.exists(): - return {} - - try: - schema_payload = json.loads(schema_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - _LOGGER.warning( - "Failed to parse SDK plugin config schema %s: %s", - schema_path, - exc, - ) - return {} - except OSError as exc: - _LOGGER.warning( - "Failed to read SDK plugin config schema %s: %s", - schema_path, - exc, - ) - return {} - if not isinstance(schema_payload, dict): - _LOGGER.warning( - "SDK plugin config schema %s must be a JSON object, got %s", - schema_path, - type(schema_payload).__name__, - ) - return {} - return schema_payload - - -def save_plugin_config( - plugin: PluginSpec, - payload: dict[str, Any], - *, - schema: dict[str, Any] | None = None, -) -> dict[str, Any]: - """按 schema 归一化并写回插件配置。""" - active_schema = ( - load_plugin_config_schema(plugin) if schema is None else dict(schema) - ) - normalized = { - key: _normalize_config_value(field_schema, payload.get(key)) - for key, field_schema in active_schema.items() - if isinstance(field_schema, dict) - } - - config_path = _plugin_config_path(plugin.plugin_dir, plugin.name) - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - json.dumps(normalized, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return normalized - - -def load_plugin_config( - plugin: PluginSpec, - *, - schema: dict[str, Any] | None = None, -) -> dict[str, Any]: - """加载插件配置,返回普通字典。""" - active_schema = ( - load_plugin_config_schema(plugin) if schema is None else dict(schema) - ) - if not active_schema: - return {} - - config_path = _plugin_config_path(plugin.plugin_dir, plugin.name) - try: - existing_payload = ( - json.loads(config_path.read_text(encoding="utf-8")) - if config_path.exists() - else {} - ) - except json.JSONDecodeError as exc: - _LOGGER.warning( - "Failed to parse SDK plugin config %s: %s", - config_path, - exc, - ) - existing_payload = {} - except OSError as exc: - _LOGGER.warning( - "Failed to read SDK plugin config %s: %s", - config_path, - exc, - ) - existing_payload = {} - existing = existing_payload if isinstance(existing_payload, dict) else {} - normalized = { - key: _normalize_config_value(field_schema, existing.get(key)) - for key, field_schema in active_schema.items() - if isinstance(field_schema, dict) - } - - if not config_path.exists() or normalized != existing: - save_plugin_config(plugin, normalized, schema=active_schema) - return normalized - - -def _is_new_star_component(cls: type[Any]) -> bool: - """检查组件类是否为 v4 新版 Star。""" - return bool(getattr(cls, "__astrbot_is_new_star__", False)) - - -def _plugin_component_classes(plugin: PluginSpec) -> list[_ResolvedComponent]: - """解析插件组件类列表。""" - components = plugin.manifest_data.get("components") or [] - if not isinstance(components, list): - return [] - - classes: list[_ResolvedComponent] = [] - for index, component in enumerate(components): - if not isinstance(component, dict): - raise ValueError( - f"{_plugin_context(plugin)} 的 components[{index}] 必须是 object。" - ) - class_path = component.get("class") - if not isinstance(class_path, str) or ":" not in class_path: - raise ValueError( - f"{_plugin_context(plugin)} 的 components[{index}].class " - "必须是 ':'。" - ) - try: - cls = import_string(class_path, plugin.plugin_dir) - except Exception as exc: - raise ValueError( - f"{_component_context(plugin, class_path=class_path, index=index)} " - f"加载失败:{exc}" - ) from exc - if not isinstance(cls, type): - raise ValueError( - f"{_component_context(plugin, class_path=class_path, index=index)} " - "解析结果不是类,请检查导出名称。" - ) - classes.append( - _ResolvedComponent( - cls=cls, - class_path=class_path, - index=index, - ) - ) - if not classes: - raise ValueError( - f"{_plugin_context(plugin)} 未声明任何可加载组件。" - "请检查 plugin.yaml 中的 components 配置。" - ) - return classes - - -def load_plugin_spec(plugin_dir: Path) -> PluginSpec: - """从插件目录加载插件规范。""" - plugin_dir = plugin_dir.resolve() - manifest_path = plugin_dir / PLUGIN_MANIFEST_FILE - requirements_path = plugin_dir / "requirements.txt" - - if not manifest_path.exists(): - raise ValueError(f"插件目录 '{plugin_dir}' 缺少 {PLUGIN_MANIFEST_FILE}。") - - manifest_data = _read_yaml(manifest_path) - runtime = manifest_data.get("runtime") or {} - python_version = runtime.get("python") or _default_python_version() - - return PluginSpec( - name=str(manifest_data.get("name") or plugin_dir.name), - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version=str(python_version), - manifest_data=manifest_data, - ) - - -def validate_plugin_spec(plugin: PluginSpec) -> None: - """校验单个插件规范,供 CLI 和发现流程复用。""" - manifest_data = plugin.manifest_data - manifest_label = f"插件 '{plugin.name}'({plugin.manifest_path})" - - raw_name = manifest_data.get("name") - if not isinstance(raw_name, str) or not raw_name: - raise ValueError(f"{manifest_label} 缺少 name。") - try: - validate_plugin_id(raw_name) - except ValueError as exc: - raise ValueError(f"{manifest_label} 的 name 不合法:{exc}") from exc - - raw_runtime = manifest_data.get("runtime") or {} - raw_python = raw_runtime.get("python") - if not isinstance(raw_python, str) or not raw_python: - raise ValueError(f"{manifest_label} 缺少 runtime.python。") - - components = manifest_data.get("components") - if not isinstance(components, list): - raise ValueError(f"{manifest_label} 的 components 必须是数组。") - - for index, component in enumerate(components): - if not isinstance(component, dict): - raise ValueError(f"{manifest_label} 的 components[{index}] 必须是 object。") - class_path = component.get("class") - if not isinstance(class_path, str) or ":" not in class_path: - raise ValueError( - f"{manifest_label} 的 components[{index}].class " - "必须是 ':'。" - ) - - -def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult: - """扫描目录发现所有插件。""" - plugins_root = plugins_dir.resolve() - skipped_plugins: dict[str, str] = {} - issues: list[PluginDiscoveryIssue] = [] - plugins: list[PluginSpec] = [] - seen_names: set[str] = set() - - if not plugins_root.exists(): - return PluginDiscoveryResult([], {}, []) - - for entry in sorted(plugins_root.iterdir()): - if not entry.is_dir() or entry.name.startswith("."): - continue - manifest_path = entry / PLUGIN_MANIFEST_FILE - if not manifest_path.exists(): - continue - - plugin: PluginSpec | None = None - try: - plugin = load_plugin_spec(entry) - validate_plugin_spec(plugin) - except Exception as exc: - skip_key = entry.name - if plugin is not None: - raw_name = plugin.manifest_data.get("name") - if isinstance(raw_name, str) and raw_name: - skip_key = raw_name - details = str(exc) - skipped_plugins[skip_key] = f"failed to parse plugin manifest: {details}" - issues.append( - PluginDiscoveryIssue( - severity="error", - phase="discovery", - plugin_id=skip_key, - message="插件发现失败", - details=details, - ) - ) - continue - - plugin_name = plugin.name - if not isinstance(plugin_name, str) or not plugin_name: - skipped_plugins[entry.name] = "plugin name is required" - issues.append( - PluginDiscoveryIssue( - severity="error", - phase="discovery", - plugin_id=entry.name, - message="插件缺少名称", - details="plugin name is required", - ) - ) - continue - if plugin_name in seen_names: - skipped_plugins[plugin_name] = "duplicate plugin name" - issues.append( - PluginDiscoveryIssue( - severity="error", - phase="discovery", - plugin_id=plugin_name, - message="插件名称重复", - details="duplicate plugin name", - ) - ) - continue - seen_names.add(plugin_name) - plugins.append(plugin) - - return PluginDiscoveryResult( - plugins=plugins, - skipped_plugins=skipped_plugins, - issues=issues, - ) - - -class PluginEnvironmentManager: - """运行时访问分组环境管理的门面层。 - - 运行时仍然保留历史上的 `prepare_environment(plugin)` 调用入口,但底层 - 实现已经变成两阶段模型: - - 1. `plan()` 负责解析跨插件分组和共享工件 - 2. `prepare_environment()` 负责把单个插件映射到它所属的分组环境 - """ - - def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None: - self.repo_root = repo_root.resolve() - self.uv_binary = uv_binary - self.cache_dir = self.repo_root / ".uv-cache" - self._planner = EnvironmentPlanner(self.repo_root, uv_binary=uv_binary) - self._group_manager = GroupEnvironmentManager( - self.repo_root, uv_binary=uv_binary - ) - self.uv_binary = self._planner.uv_binary - self._plan_result: EnvironmentPlanResult | None = None - - def plan(self, plugins: list[PluginSpec]) -> EnvironmentPlanResult: - """为当前插件集合生成共享环境规划。""" - plan_result = self._planner.plan(plugins) - self._plan_result = plan_result - return plan_result - - def prepare_group_environment(self, group: EnvironmentGroup) -> Path: - """返回指定分组的解释器路径。""" - if self._plan_result is None: - self._plan_result = EnvironmentPlanResult(groups=[group]) - return self._group_manager.prepare(group) - - def prepare_environment(self, plugin: PluginSpec) -> Path: - """返回该插件所属分组环境的解释器路径。 - - 如果调用方还没有先对整批插件做规划,这里会自动创建一个至少包含当 - 前插件的最小规划,以保证旧的"单插件直接调用"模式仍然可用。 - """ - if ( - self._plan_result is None - or plugin.name not in self._plan_result.plugin_to_group - ): - planned_plugins = ( - list(self._plan_result.plugins) if self._plan_result else [] - ) - if plugin.name not in {item.name for item in planned_plugins}: - planned_plugins.append(plugin) - self.plan(planned_plugins) - - assert self._plan_result is not None - group = self._plan_result.plugin_to_group.get(plugin.name) - if group is None: - reason = self._plan_result.skipped_plugins.get(plugin.name) - if reason is not None: - raise RuntimeError(reason) - raise RuntimeError(f"environment plan missing plugin: {plugin.name}") - - return self.prepare_group_environment(group) - - @staticmethod - def _fingerprint(plugin: PluginSpec) -> str: - requirements = _read_requirements_text(plugin.requirements_path) - payload = { - "python_version": plugin.python_version, - "requirements": requirements, - } - return json.dumps(payload, ensure_ascii=True, sort_keys=True) - - @staticmethod - def _load_state(state_path: Path) -> dict[str, Any]: - if not state_path.exists(): - return {} - try: - data = json.loads(state_path.read_text(encoding="utf-8")) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - @staticmethod - def _write_state(state_path: Path, plugin: PluginSpec, fingerprint: str) -> None: - state_path.write_text( - json.dumps( - { - "plugin": plugin.name, - "python_version": plugin.python_version, - "fingerprint": fingerprint, - }, - ensure_ascii=True, - indent=2, - sort_keys=True, - ), - encoding="utf-8", - ) - - @staticmethod - def _matches_python_version(venv_dir: Path, version: str) -> bool: - pyvenv_cfg = venv_dir / "pyvenv.cfg" - if not pyvenv_cfg.exists(): - return False - try: - content = pyvenv_cfg.read_text(encoding="utf-8") - except OSError: - return False - match = re.search(r"version\s*=\s*(\d+\.\d+)\.\d+", content, re.IGNORECASE) - return match is not None and match.group(1) == version - - -def load_plugin(plugin: PluginSpec) -> LoadedPlugin: - """加载插件,返回处理器和能力列表。 - - 仅支持 v4 新版 Star 组件(无参构造函数)。 - """ - plugin_path = str(plugin.plugin_dir) - if plugin_path not in sys.path: - sys.path.insert(0, plugin_path) - _purge_plugin_bytecode(plugin.plugin_dir) - _purge_plugin_modules(plugin.plugin_dir) - - instances: list[Any] = [] - handlers: list[LoadedHandler] = [] - capabilities: list[LoadedCapability] = [] - llm_tools: list[LoadedLLMTool] = [] - agents: list[LoadedAgent] = [] - seen_agents: set[str] = set() - - for resolved_component in _plugin_component_classes(plugin): - component_cls = resolved_component.cls - if not _is_new_star_component(component_cls): - raise ValueError( - f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} " - f"解析到的类 {component_cls.__module__}.{component_cls.__qualname__} " - "不是 v4 Star 组件。请继承 astrbot_sdk.Star。" - ) - try: - instance = component_cls() - except Exception as exc: - raise ValueError( - f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} " - f"实例化失败:{exc}" - ) from exc - instances.append(instance) - - for runner_class, meta in _iter_agent_candidates(component_cls): - runner_key = f"{runner_class.__module__}.{runner_class.__qualname__}" - if runner_key in seen_agents: - continue - seen_agents.add(runner_key) - agents.append( - LoadedAgent( - spec=meta.spec.model_copy(deep=True), - runner_class=runner_class, - owner=None, - plugin_id=plugin.name, - ) - ) - - for name in _iter_discoverable_names(instance): - resolved = _resolve_handler_candidate(instance, name) - capability = _resolve_capability_candidate(instance, name) - llm_tool = _resolve_llm_tool_candidate(instance, name) - if resolved is None and capability is None and llm_tool is None: - continue - if capability is not None: - bound, meta = capability - capabilities.append( - LoadedCapability( - descriptor=meta.descriptor.model_copy(deep=True), - callable=bound, - owner=instance, - plugin_id=plugin.name, - ) - ) - if llm_tool is not None: - bound_tool, tool_meta = llm_tool - llm_tools.append( - LoadedLLMTool( - spec=tool_meta.spec.model_copy(deep=True), - callable=bound_tool, - owner=instance, - plugin_id=plugin.name, - ), - ) - if resolved is not None: - bound, meta = resolved - handler_id = f"{plugin.name}:{instance.__class__.__module__}.{instance.__class__.__name__}.{name}" - if isinstance(meta.trigger, ScheduleTrigger): - _validate_schedule_signature(bound) - param_specs = _build_param_specs(bound) - handlers.append( - LoadedHandler( - descriptor=HandlerDescriptor( - id=handler_id, - trigger=meta.trigger, - kind=cast(HandlerKind, meta.kind), - contract=meta.contract, - description=meta.description, - priority=meta.priority, - permissions=meta.permissions.model_copy(deep=True), - filters=[ - item.model_copy(deep=True) for item in meta.filters - ], - param_specs=[ - item.model_copy(deep=True) for item in param_specs - ], - command_route=( - meta.command_route.model_copy(deep=True) - if meta.command_route is not None - else None - ), - ), - callable=bound, - owner=instance, - plugin_id=plugin.name, - local_filters=list(meta.local_filters), - limiter=( - None - if meta.limiter is None - else LimiterMeta( - kind=meta.limiter.kind, - limit=meta.limiter.limit, - window=meta.limiter.window, - scope=meta.limiter.scope, - behavior=meta.limiter.behavior, - message=meta.limiter.message, - ) - ), - conversation=( - None - if meta.conversation is None - else ConversationMeta( - timeout=meta.conversation.timeout, - mode=meta.conversation.mode, - busy_message=meta.conversation.busy_message, - grace_period=meta.conversation.grace_period, - ) - ), - ) - ) - - return LoadedPlugin( - plugin=plugin, - handlers=handlers, - capabilities=capabilities, - llm_tools=llm_tools, - agents=agents, - instances=instances, - ) - - -def _path_within_root(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - except ValueError: - return False - return True - - -def _plugin_defines_module_root(plugin_dir: Path, root_name: str) -> bool: - return (plugin_dir / f"{root_name}.py").exists() or ( - plugin_dir / root_name - ).exists() - - -def _module_belongs_to_plugin(module: Any, plugin_dir: Path) -> bool: - file_path = getattr(module, "__file__", None) - if isinstance(file_path, str) and _path_within_root(Path(file_path), plugin_dir): - return True - - package_paths = getattr(module, "__path__", None) - if package_paths is None: - return False - return any( - isinstance(candidate, str) and _path_within_root(Path(candidate), plugin_dir) - for candidate in package_paths - ) - - -def _purge_plugin_modules(plugin_dir: Path) -> None: - plugin_root = plugin_dir.resolve() - for module_name, module in list(sys.modules.items()): - if module is None: - continue - if _module_belongs_to_plugin(module, plugin_root): - sys.modules.pop(module_name, None) - - -def _purge_plugin_bytecode(plugin_dir: Path) -> None: - plugin_root = plugin_dir.resolve() - for path in plugin_root.rglob("*"): - try: - if path.is_dir() and path.name == "__pycache__": - shutil.rmtree(path, ignore_errors=True) - continue - if path.is_file() and path.suffix in {".pyc", ".pyo"}: - path.unlink(missing_ok=True) - except OSError: - continue - - -def _purge_module_root(root_name: str) -> None: - for module_name in list(sys.modules): - if module_name == root_name or module_name.startswith(f"{root_name}."): - sys.modules.pop(module_name, None) - - -def _prepare_plugin_import(module_name: str, plugin_dir: Path | None) -> None: - if plugin_dir is None: - return - - plugin_root = plugin_dir.resolve() - plugin_path = str(plugin_root) - sys.path[:] = [entry for entry in sys.path if entry != plugin_path] - sys.path.insert(0, plugin_path) - - root_name = module_name.split(".", 1)[0] - if not _plugin_defines_module_root(plugin_root, root_name): - return - - cached_root = sys.modules.get(root_name) - cached_module = sys.modules.get(module_name) - if cached_root is not None and not _module_belongs_to_plugin( - cached_root, plugin_root - ): - _purge_module_root(root_name) - elif cached_module is not None and not _module_belongs_to_plugin( - cached_module, plugin_root - ): - _purge_module_root(root_name) - - importlib.invalidate_caches() - - -def import_string(path: str, plugin_dir: Path | None = None) -> Any: - """通过字符串路径导入对象。""" - module_name, attr = path.split(":", 1) - _prepare_plugin_import(module_name, plugin_dir) - module = import_module(module_name) - return getattr(module, attr) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/peer.py b/astrbot-sdk/src/astrbot_sdk/runtime/peer.py deleted file mode 100644 index 6259c50ab..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/peer.py +++ /dev/null @@ -1,775 +0,0 @@ -"""协议对等端模块。 - -定义 Peer 类,封装双向传输通道上的消息收发、初始化握手、能力调用、 -流式事件转发与取消处理。这里的 peer 指"通信对端/本端"这一网络协议概念, -而不是业务上的用户、群聊或会话对象。 - -核心职责: - - 消息序列化/反序列化 - - 初始化握手协议 - - 能力调用(同步/流式) - - 取消处理 - - 连接生命周期管理 -消息处理: - 入站: - ResultMessage -> 唤醒等待的 Future - EventMessage -> 投递到流式队列 - InitializeMessage -> 调用 initialize_handler - InvokeMessage -> 创建任务调用 invoke_handler - CancelMessage -> 取消对应的任务 - - 出站: - initialize() -> InitializeMessage - invoke() -> InvokeMessage(stream=False) - invoke_stream() -> InvokeMessage(stream=True) - cancel() -> CancelMessage - -使用示例: - # 作为客户端发起调用 - peer = Peer(transport=transport, peer_info=PeerInfo(...)) - await peer.start() - output = await peer.initialize(handlers) - result = await peer.invoke("llm.chat", {"prompt": "hello"}) - - # 作为服务端处理调用 - peer.set_invoke_handler(my_handler) - await peer.start() - -消息处理流程: - 入站消息: - ResultMessage -> 唤醒等待的 Future - EventMessage -> 投递到流式队列 - InitializeMessage -> 调用 _initialize_handler - InvokeMessage -> 创建任务调用 _invoke_handler - CancelMessage -> 取消对应的任务 - - 出站消息: - initialize() -> InitializeMessage - invoke() -> InvokeMessage(stream=False) - invoke_stream() -> InvokeMessage(stream=True) - cancel() -> CancelMessage - -取消机制: - - CancelToken 用于检查取消状态 - - 入站任务在收到 CancelMessage 时被取消 - - 早到取消:在任务执行前检查 cancel_token,避免竞态条件 - -`Peer` 把 `Transport` 和 v4 协议消息模型接起来,负责: - -- 握手与远端元数据缓存 -- 请求 ID 关联 -- 非流式 / 流式调用分发 -- 取消传播 -- 连接异常时的统一收口 - -它本身不做业务路由,真正的执行逻辑交给 `CapabilityRouter` 或 -`HandlerDispatcher`。 -""" - -from __future__ import annotations - -import asyncio -import inspect -from collections.abc import AsyncIterator, Awaitable, Callable, Sequence -from typing import Any - -from loguru import logger - -from .._internal.invocation_context import ( - caller_plugin_scope, - current_caller_plugin_id, -) -from ..context import CancelToken -from ..errors import AstrBotError, ErrorCodes -from ..protocol.messages import ( - CancelMessage, - ErrorPayload, - EventMessage, - InitializeMessage, - InitializeOutput, - InvokeMessage, - PeerInfo, - ResultMessage, - parse_message, -) -from .capability_router import StreamExecution - -InitializeHandler = Callable[[InitializeMessage], Awaitable[InitializeOutput]] -InvokeHandler = Callable[ - [InvokeMessage, CancelToken], Awaitable[dict[str, Any] | StreamExecution] -] -CancelHandler = Callable[[str], Awaitable[None]] - -SUPPORTED_PROTOCOL_VERSIONS_METADATA_KEY = "supported_protocol_versions" -NEGOTIATED_PROTOCOL_VERSION_METADATA_KEY = "negotiated_protocol_version" - - -def _dedupe_protocol_versions( - versions: Sequence[str] | None, *, preferred_version: str -) -> list[str]: - ordered_versions: list[str] = [preferred_version] - if versions is not None: - ordered_versions.extend(versions) - deduped: list[str] = [] - for version in ordered_versions: - if not isinstance(version, str) or not version: - continue - if version not in deduped: - deduped.append(version) - return deduped - - -def _parse_protocol_version(version: str) -> tuple[int, int] | None: - major, dot, minor = version.partition(".") - if not dot or not major.isdigit() or not minor.isdigit(): - return None - return int(major), int(minor) - - -def _select_negotiated_protocol_version( - requested_version: str, - remote_metadata: dict[str, Any], - local_supported_versions: Sequence[str], -) -> str | None: - if requested_version in local_supported_versions: - return requested_version - requested_key = _parse_protocol_version(requested_version) - if requested_key is None: - return None - remote_supported = remote_metadata.get(SUPPORTED_PROTOCOL_VERSIONS_METADATA_KEY) - if not isinstance(remote_supported, (list, tuple)): - return None - local_supported_set = set(local_supported_versions) - compatible_versions: list[tuple[tuple[int, int], str]] = [] - for version in remote_supported: - if not isinstance(version, str) or version not in local_supported_set: - continue - parsed_version = _parse_protocol_version(version) - if parsed_version is None: - continue - if parsed_version[0] != requested_key[0] or parsed_version > requested_key: - continue - compatible_versions.append((parsed_version, version)) - if not compatible_versions: - return None - compatible_versions.sort(reverse=True) - return compatible_versions[0][1] - - -class Peer: - """表示协议连接中的一个对等端。 - - `Peer` 封装一条双向传输通道上的消息收发、初始化握手、能力调用、 - 流式事件转发与取消处理。这里的 `peer` 指“通信对端/本端”这一网络 - 协议概念,而不是业务上的用户、群聊或会话对象。 - """ - - def __init__( - self, - *, - transport, - peer_info: PeerInfo, - protocol_version: str = "1.0", - supported_protocol_versions: Sequence[str] | None = None, - ) -> None: - """创建一个协议对等端实例。 - - Args: - transport: 底层传输实现,负责发送字符串消息并回调入站消息。 - peer_info: 当前端点对外声明的身份信息。 - protocol_version: 当前端点首选的协议版本,用于初始化握手。 - supported_protocol_versions: 当前端点可接受的协议版本列表。 - """ - self.transport = transport - self.peer_info = peer_info - self.protocol_version = protocol_version - self.supported_protocol_versions = _dedupe_protocol_versions( - supported_protocol_versions, - preferred_version=protocol_version, - ) - self.negotiated_protocol_version: str | None = None - self.remote_peer: PeerInfo | None = None - self.remote_handlers = [] - self.remote_provided_capabilities = [] - self.remote_capabilities = [] - self.remote_capability_map: dict[str, Any] = {} - self.remote_provided_capability_map: dict[str, Any] = {} - self.remote_metadata: dict[str, Any] = {} - - self._initialize_handler: InitializeHandler | None = None - self._invoke_handler: InvokeHandler | None = None - self._cancel_handler: CancelHandler | None = None - self._counter = 0 - self._closed = asyncio.Event() - self._unusable = False - self._stopping = False - self._pending_results: dict[str, asyncio.Future[ResultMessage]] = {} - self._pending_streams: dict[str, asyncio.Queue[Any]] = {} - self._inbound_tasks: dict[ - str, tuple[asyncio.Task[None], CancelToken, asyncio.Event] - ] = {} - self._remote_initialized = asyncio.Event() - self._remote_initialized_successfully = False - self._transport_watch_task: asyncio.Task[None] | None = None - - def set_initialize_handler(self, handler: InitializeHandler) -> None: - """注册处理远端 `initialize` 请求的握手处理器。""" - self._initialize_handler = handler - - def set_invoke_handler(self, handler: InvokeHandler) -> None: - """注册处理远端 `invoke` 请求的能力调用处理器。""" - self._invoke_handler = handler - - def set_cancel_handler(self, handler: CancelHandler) -> None: - """注册处理远端 `cancel` 请求的取消回调。""" - self._cancel_handler = handler - - async def start(self) -> None: - """启动传输层并将原始入站消息绑定到当前 `Peer`。""" - self._closed.clear() - self._unusable = False - self._stopping = False - self.negotiated_protocol_version = None - self._remote_initialized.clear() - self._remote_initialized_successfully = False - self.transport.set_message_handler(self._handle_raw_message) - await self.transport.start() - self._transport_watch_task = asyncio.create_task(self._watch_transport_closed()) - - async def stop(self) -> None: - """关闭 `Peer` 并清理所有挂起中的请求、流和入站任务。""" - if self._closed.is_set(): - return - self._stopping = True - # 终止所有挂起的 RPC,避免调用方永久挂起 - for future in list(self._pending_results.values()): - if not future.done(): - future.set_exception(AstrBotError.internal_error("连接已关闭")) - self._pending_results.clear() - - for queue in list(self._pending_streams.values()): - await queue.put(AstrBotError.internal_error("连接已关闭")) - self._pending_streams.clear() - - # 取消所有入站任务 - for task, token, _started in list(self._inbound_tasks.values()): - token.cancel() - task.cancel() - self._inbound_tasks.clear() - - await self.transport.stop() - self._closed.set() - - async def wait_closed(self) -> None: - """等待底层传输彻底关闭。""" - await self.transport.wait_closed() - - async def _watch_transport_closed(self) -> None: - """监视底层传输的意外关闭,并主动失败挂起调用。""" - try: - await self.transport.wait_closed() - if self._closed.is_set() or self._stopping: - return - await self._fail_connection( - AstrBotError( - code=ErrorCodes.NETWORK_ERROR, - message="连接已关闭", - hint="请检查对端进程或传输连接", - retryable=True, - ) - ) - finally: - current_task = asyncio.current_task() - if self._transport_watch_task is current_task: - self._transport_watch_task = None - - async def wait_until_remote_initialized(self, timeout: float | None = 30.0) -> None: - """等待远端完成初始化握手。 - - Args: - timeout: 等待秒数。传入 `None` 表示无限等待。 - """ - init_waiter = asyncio.create_task(self._remote_initialized.wait()) - closed_waiter = asyncio.create_task(self.wait_closed()) - try: - done, pending = await asyncio.wait( - {init_waiter, closed_waiter}, - timeout=timeout, - return_when=asyncio.FIRST_COMPLETED, - ) - if not done: - raise TimeoutError() - if init_waiter in done and self._remote_initialized_successfully: - return - raise AstrBotError.protocol_error("连接在初始化完成前关闭") - finally: - for task in (init_waiter, closed_waiter): - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - async def initialize( - self, - handlers, - *, - provided_capabilities=None, - metadata: dict[str, Any] | None = None, - ) -> InitializeOutput: - """向远端发送初始化请求并缓存远端声明的能力信息。 - - Args: - handlers: 当前端点声明可接收的处理器列表。 - metadata: 附带给远端的握手元数据。 - - Returns: - 远端返回的初始化结果。 - """ - self._ensure_usable() - request_id = self._next_id() - handshake_metadata = dict(metadata or {}) - handshake_metadata[SUPPORTED_PROTOCOL_VERSIONS_METADATA_KEY] = list( - self.supported_protocol_versions - ) - future: asyncio.Future[ResultMessage] = ( - asyncio.get_running_loop().create_future() - ) - self._pending_results[request_id] = future - await self._send( - InitializeMessage( - id=request_id, - protocol_version=self.protocol_version, - peer=self.peer_info, - handlers=list(handlers), - provided_capabilities=list(provided_capabilities or []), - metadata=handshake_metadata, - ) - ) - result = await future - if result.kind != "initialize_result": - raise AstrBotError.protocol_error("initialize 必须收到 initialize_result") - if not result.success: - self._unusable = True - await self.stop() - raise AstrBotError.from_payload( - result.error.model_dump() if result.error else {} - ) - output = InitializeOutput.model_validate(result.output) - negotiated_protocol_version = ( - output.protocol_version - or output.metadata.get(NEGOTIATED_PROTOCOL_VERSION_METADATA_KEY) - or self.protocol_version - ) - if ( - not isinstance(negotiated_protocol_version, str) - or negotiated_protocol_version not in self.supported_protocol_versions - ): - self._unusable = True - await self.stop() - raise AstrBotError.protocol_version_mismatch( - f"对端返回了当前端点不支持的协商协议版本:{negotiated_protocol_version}" - ) - self.remote_peer = output.peer - self.remote_capabilities = output.capabilities - self.remote_capability_map = {item.name: item for item in output.capabilities} - self.remote_metadata = output.metadata - self.negotiated_protocol_version = negotiated_protocol_version - self._remote_initialized_successfully = True - self._remote_initialized.set() - return output - - async def invoke( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, Any]: - """发起一次非流式能力调用并等待最终结果。 - - Args: - capability: 远端能力名。 - payload: 调用输入。 - stream: 必须为 `False`;流式场景应改用 `invoke_stream()`。 - request_id: 可选的请求 ID;未提供时自动生成。 - """ - self._ensure_usable() - if stream: - raise ValueError("stream=True 请使用 invoke_stream()") - request_id = request_id or self._next_id() - future: asyncio.Future[ResultMessage] = ( - asyncio.get_running_loop().create_future() - ) - self._pending_results[request_id] = future - await self._send( - InvokeMessage( - id=request_id, - capability=capability, - input=payload, - stream=False, - caller_plugin_id=current_caller_plugin_id(), - ) - ) - result = await future - if not result.success: - raise AstrBotError.from_payload( - result.error.model_dump() if result.error else {} - ) - return result.output - - async def invoke_stream( - self, - capability: str, - payload: dict[str, Any], - *, - request_id: str | None = None, - include_completed: bool = False, - ) -> AsyncIterator[EventMessage]: - """发起一次流式能力调用并返回事件迭代器。 - - 调用方会收到 `delta` 事件,`started` 会被内部吞掉, - 默认情况下 `completed` 用于结束迭代,`failed` 会转换为异常抛出。 - - Args: - capability: 远端能力名。 - payload: 调用输入。 - request_id: 可选的请求 ID;未提供时自动生成。 - include_completed: 是否把 `completed` 事件也返回给调用方。 - """ - self._ensure_usable() - request_id = request_id or self._next_id() - queue: asyncio.Queue[Any] = asyncio.Queue() - self._pending_streams[request_id] = queue - await self._send( - InvokeMessage( - id=request_id, - capability=capability, - input=payload, - stream=True, - caller_plugin_id=current_caller_plugin_id(), - ) - ) - - async def iterator() -> AsyncIterator[EventMessage]: - try: - while True: - item = await queue.get() - if isinstance(item, Exception): - raise item - if not isinstance(item, EventMessage): - raise AstrBotError.protocol_error("流式调用收到非法事件") - if item.phase == "started": - continue - if item.phase == "delta": - yield item - continue - if item.phase == "completed": - if include_completed: - yield item - break - if item.phase == "failed": - raise AstrBotError.from_payload( - item.error.model_dump() if item.error else {} - ) - finally: - self._pending_streams.pop(request_id, None) - - return iterator() - - async def cancel(self, request_id: str, reason: str = "user_cancelled") -> None: - """向远端发送取消请求,尝试中止指定 ID 的在途调用。""" - await self._send(CancelMessage(id=request_id, reason=reason)) - - def _next_id(self) -> str: - """生成当前连接内递增的消息 ID。""" - self._counter += 1 - return f"msg_{self._counter:04d}" - - def _ensure_usable(self) -> None: - """确保连接仍处于可用状态,否则立即抛出协议错误。""" - if self._unusable: - raise AstrBotError.protocol_error("连接已进入不可用状态") - - async def _handle_raw_message(self, payload: str) -> None: - """解析原始消息并分发到对应的消息处理分支。""" - try: - message = parse_message(payload) - if isinstance(message, ResultMessage): - await self._handle_result(message) - return - if isinstance(message, EventMessage): - await self._handle_event(message) - return - if isinstance(message, InitializeMessage): - await self._handle_initialize(message) - return - if isinstance(message, InvokeMessage): - token = CancelToken() - started = asyncio.Event() - task = asyncio.create_task(self._handle_invoke(message, token, started)) - self._inbound_tasks[message.id] = (task, token, started) - - def _on_invoke_done( - _task: asyncio.Task[None], request_id: str = message.id - ) -> None: - self._inbound_tasks.pop(request_id, None) - if _task.cancelled(): - return - exc = _task.exception() - if exc is None: - return - # 后台 invoke 理论上应把错误编码成协议消息;若异常仍逃逸,通常说明 - # 回复发送失败或连接状态异常,必须立刻标记连接失效,避免对端永久等待。 - logger.error( - "Peer inbound invoke task crashed unexpectedly: " - "request_id={} error={!r}", - request_id, - exc, - ) - error = ( - exc - if isinstance(exc, AstrBotError) - else AstrBotError( - code=ErrorCodes.NETWORK_ERROR, - message="处理入站调用响应时连接已失效", - hint=str(exc), - retryable=True, - ) - ) - asyncio.create_task(self._fail_connection(error)) - - task.add_done_callback(_on_invoke_done) - return - if isinstance(message, CancelMessage): - await self._handle_cancel(message) - return - except Exception as exc: - if isinstance(exc, AstrBotError): - error = exc - else: - error = AstrBotError.protocol_error(f"无法解析协议消息: {exc}") - await self._fail_connection(error) - raise error from exc - - async def _handle_initialize(self, message: InitializeMessage) -> None: - """处理远端发起的初始化握手并返回握手结果。""" - self.remote_peer = message.peer - self.remote_handlers = message.handlers - self.remote_provided_capabilities = message.provided_capabilities - self.remote_provided_capability_map = { - item.name: item for item in message.provided_capabilities - } - self.remote_metadata = dict(message.metadata) - if self._initialize_handler is None: - await self._reject_initialize( - message, - AstrBotError.protocol_error("对端不接受 initialize"), - ) - return - - negotiated_protocol_version = _select_negotiated_protocol_version( - message.protocol_version, - self.remote_metadata, - self.supported_protocol_versions, - ) - if negotiated_protocol_version is None: - supported_versions = ", ".join(self.supported_protocol_versions) - await self._reject_initialize( - message, - AstrBotError.protocol_version_mismatch( - "服务端支持协议版本 " - f"{supported_versions},客户端请求版本 {message.protocol_version}" - ), - ) - return - - self.negotiated_protocol_version = negotiated_protocol_version - self.remote_metadata[NEGOTIATED_PROTOCOL_VERSION_METADATA_KEY] = ( - negotiated_protocol_version - ) - output = await self._initialize_handler(message) - response_metadata = dict(output.metadata) - response_metadata[NEGOTIATED_PROTOCOL_VERSION_METADATA_KEY] = ( - negotiated_protocol_version - ) - output = output.model_copy( - update={ - "protocol_version": negotiated_protocol_version, - "metadata": response_metadata, - } - ) - await self._send( - ResultMessage( - id=message.id, - kind="initialize_result", - success=True, - output=output.model_dump(), - ) - ) - self._remote_initialized_successfully = True - self._remote_initialized.set() - - async def _handle_invoke( - self, - message: InvokeMessage, - token: CancelToken, - started: asyncio.Event, - ) -> None: - """处理远端发起的能力调用,并按流式或非流式协议返回结果。""" - try: - started.set() - token.raise_if_cancelled() - if self._invoke_handler is None: - raise AstrBotError.capability_not_found(message.capability) - with caller_plugin_scope(message.caller_plugin_id): - execution = await self._invoke_handler(message, token) - if inspect.isawaitable(execution): - execution = await execution - if message.stream: - if not isinstance(execution, StreamExecution): - raise AstrBotError.protocol_error( - "stream=true 必须返回 StreamExecution" - ) - await self._send(EventMessage(id=message.id, phase="started")) - collect_chunks = execution.collect_chunks - chunks: list[dict[str, Any]] = [] - async for chunk in execution.iterator: - if collect_chunks: - chunks.append(chunk) - await self._send( - EventMessage(id=message.id, phase="delta", data=chunk) - ) - await self._send( - EventMessage( - id=message.id, - phase="completed", - output=execution.finalize(chunks), - ) - ) - return - if isinstance(execution, StreamExecution): - raise AstrBotError.protocol_error("stream=false 不能返回流式执行对象") - await self._send( - ResultMessage(id=message.id, success=True, output=execution) - ) - except asyncio.CancelledError: - await self._send_cancelled_termination(message) - except LookupError as exc: - error = AstrBotError.invalid_input(str(exc)) - await self._send_error_result(message, error) - except AstrBotError as exc: - await self._send_error_result(message, exc) - except Exception as exc: - await self._send_error_result( - message, AstrBotError.internal_error(str(exc)) - ) - - async def _handle_cancel(self, message: CancelMessage) -> None: - """处理远端取消请求并终止对应的入站任务。""" - inbound = self._inbound_tasks.get(message.id) - if inbound is None: - return - task, token, started = inbound - token.cancel() - if self._cancel_handler is not None: - await self._cancel_handler(message.id) - if started.is_set(): - task.cancel() - - async def _handle_result(self, message: ResultMessage) -> None: - """处理非流式结果消息并唤醒等待中的调用方。""" - future = self._pending_results.pop(message.id, None) - if future is None: - queue = self._pending_streams.get(message.id) - if queue is not None: - await queue.put( - AstrBotError.protocol_error("stream=true 调用不应收到 result") - ) - return - # 检查 future 是否已完成(可能被调用方取消) - if not future.done(): - future.set_result(message) - - async def _handle_event(self, message: EventMessage) -> None: - """处理流式事件消息并投递到对应请求的事件队列。""" - queue = self._pending_streams.get(message.id) - if queue is None: - future = self._pending_results.get(message.id) - if future is not None and not future.done(): - future.set_exception( - AstrBotError.protocol_error("stream=false 调用不应收到 event") - ) - return - await queue.put(message) - - async def _send_error_result( - self, message: InvokeMessage, error: AstrBotError - ) -> None: - """根据调用模式,将错误编码为 `result` 或失败事件发回远端。""" - if message.stream: - await self._send( - EventMessage( - id=message.id, - phase="failed", - error=ErrorPayload.model_validate(error.to_payload()), - ) - ) - return - await self._send( - ResultMessage( - id=message.id, - success=False, - error=ErrorPayload.model_validate(error.to_payload()), - ) - ) - - async def _reject_initialize( - self, message: InitializeMessage, error: AstrBotError - ) -> None: - """拒绝一次初始化握手,并把连接标记为不可继续使用。""" - await self._send( - ResultMessage( - id=message.id, - kind="initialize_result", - success=False, - error=ErrorPayload.model_validate(error.to_payload()), - ) - ) - self._unusable = True - self._remote_initialized.set() - await self.stop() - - async def _send_cancelled_termination(self, message: InvokeMessage) -> None: - """把本端取消执行转换为标准化的取消错误响应。""" - error = AstrBotError.cancelled() - await self._send_error_result(message, error) - - async def _fail_connection(self, error: AstrBotError) -> None: - """把连接标记为不可用,并让所有等待中的调用尽快失败。""" - if self._unusable: - return - self._unusable = True - self._remote_initialized.set() - - for future in list(self._pending_results.values()): - if not future.done(): - future.set_exception(error) - self._pending_results.clear() - - for queue in list(self._pending_streams.values()): - await queue.put(error) - self._pending_streams.clear() - - for task, token, _started in list(self._inbound_tasks.values()): - token.cancel() - task.cancel() - self._inbound_tasks.clear() - - asyncio.create_task(self.stop()) - - async def _send(self, message) -> None: - """序列化协议消息并通过底层传输发送出去。""" - await self.transport.send(message.model_dump_json(exclude_none=True)) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/supervisor.py b/astrbot-sdk/src/astrbot_sdk/runtime/supervisor.py deleted file mode 100644 index 1727f218f..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/supervisor.py +++ /dev/null @@ -1,871 +0,0 @@ -"""Supervisor 端运行时:SupervisorRuntime 管理多个 Worker 进程,WorkerSession 封装与单个 Worker 的通信。 - -架构层次: - AstrBot Core (Python) - | - v - SupervisorRuntime (管理多插件) - | - +-- WorkerSession (插件 A) -- StdioTransport -- PluginWorkerRuntime (子进程) - | - +-- WorkerSession (插件 B) -- StdioTransport -- PluginWorkerRuntime (子进程) - | - +-- WorkerSession (插件 C) -- StdioTransport -- PluginWorkerRuntime (子进程) - -核心类: - SupervisorRuntime: 监管者运行时 - - 发现并加载所有插件 - - 为每个插件启动 Worker 进程 - - 聚合所有 handler 并向 Core 注册 - - 路由 Core 的调用请求到对应 Worker - - 处理 Worker 进程崩溃和重连 - - handler ID 冲突检测和警告 - - WorkerSession: Worker 会话 - - 管理单个插件 Worker 进程 - - 通过 Peer 与 Worker 通信 - - 提供 invoke_handler 和 cancel 方法 - - 处理连接关闭回调 - - 自动清理已注册的 handlers - -信号处理: - - SIGTERM: 设置 stop_event,触发优雅关闭 - - SIGINT: 设置 stop_event,触发优雅关闭 -""" - -from __future__ import annotations - -import asyncio -import os -import signal -import sys -from collections.abc import Callable -from pathlib import Path -from typing import IO, Any, cast - -from loguru import logger - -from ..errors import AstrBotError -from ..protocol.descriptors import CapabilityDescriptor -from ..protocol.messages import EventMessage, InitializeOutput, PeerInfo -from .capability_router import CapabilityRouter, StreamExecution -from .environment_groups import EnvironmentGroup -from .loader import ( - PluginDiscoveryIssue, - PluginEnvironmentManager, - PluginSpec, - discover_plugins, - load_plugin_config, -) -from .peer import Peer -from .transport import StdioTransport - -__all__ = [ - "SupervisorRuntime", - "WorkerSession", - "_install_signal_handlers", - "_prepare_stdio_transport", - "_sdk_source_dir", - "_wait_for_shutdown", -] - - -def _install_signal_handlers(stop_event: asyncio.Event) -> None: - loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT): - try: - loop.add_signal_handler(sig, stop_event.set) - except NotImplementedError: - logger.debug("Signal handlers are not supported for {}", sig) - - -def _prepare_stdio_transport( - stdin: IO[str] | None, - stdout: IO[str] | None, -) -> tuple[IO[str], IO[str], IO[str] | None]: - if stdin is not None and stdout is not None: - return stdin, stdout, None - transport_stdin = stdin or sys.stdin - transport_stdout = stdout or sys.stdout - original_stdout = sys.stdout - sys.stdout = sys.stderr - return transport_stdin, transport_stdout, original_stdout - - -def _sdk_source_dir(repo_root: Path) -> Path: - candidate = repo_root.resolve() / "src" - if (candidate / "astrbot_sdk").exists(): - return candidate - return Path(__file__).resolve().parents[2] - - -async def _wait_for_shutdown(peer: Peer, stop_event: asyncio.Event) -> None: - stop_waiter = asyncio.create_task(stop_event.wait()) - transport_waiter = asyncio.create_task(peer.wait_closed()) - done, pending = await asyncio.wait( - {stop_waiter, transport_waiter}, - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - for task in done: - if not task.cancelled(): - task.result() - - -def _plugin_name_from_handler_id(handler_id: str) -> str: - if ":" in handler_id: - return handler_id.split(":", 1)[0] - return handler_id - - -class WorkerSession: - def __init__( - self, - *, - plugin: PluginSpec | None = None, - group: EnvironmentGroup | None = None, - repo_root: Path, - env_manager: PluginEnvironmentManager, - capability_router: CapabilityRouter, - on_closed: Callable[[], None] | None = None, - ) -> None: - if plugin is None and group is None: - raise ValueError("WorkerSession requires either plugin or group") - group_ref = group - if group_ref is not None: - primary_plugin = group_ref.plugins[0] - else: - assert plugin is not None - primary_plugin = plugin - self.group = group - self.plugins = ( - list(group_ref.plugins) if group_ref is not None else [primary_plugin] - ) - self.plugin = primary_plugin - self.group_id = group_ref.id if group_ref is not None else primary_plugin.name - self.repo_root = repo_root.resolve() - self.env_manager = env_manager - self.capability_router = capability_router - self.on_closed = on_closed - self.peer: Peer | None = None - self.handlers = [] - self.provided_capabilities: list[CapabilityDescriptor] = [] - self.loaded_plugins: list[str] = [] - self.skipped_plugins: dict[str, str] = {} - self.issues: list[PluginDiscoveryIssue] = [] - self.capability_sources: dict[str, str] = {} - self.llm_tools: list[dict[str, Any]] = [] - self.agents: list[dict[str, Any]] = [] - self._connection_watch_task: asyncio.Task[None] | None = None - - async def start(self) -> None: - python_path, command, cwd = self._worker_command() - repo_src_dir = str(_sdk_source_dir(self.repo_root)) - env = os.environ.copy() - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - f"{repo_src_dir}{os.pathsep}{existing_pythonpath}" - if existing_pythonpath - else repo_src_dir - ) - env.setdefault("PYTHONIOENCODING", "utf-8") - env.setdefault("PYTHONUTF8", "1") - - transport = StdioTransport( - command=command, - cwd=cwd, - env=env, - ) - self.peer = Peer( - transport=transport, - peer_info=PeerInfo(name="astrbot-core", role="core", version="v4"), - ) - self.peer.set_initialize_handler(self._handle_initialize) - self.peer.set_invoke_handler(self._handle_capability_invoke) - try: - await self.peer.start() - # 同时监听初始化完成和连接关闭,避免 worker 崩溃时等满超时 - init_task = asyncio.create_task( - self.peer.wait_until_remote_initialized(timeout=None) - ) - closed_task = asyncio.create_task(self.peer.wait_closed()) - done, pending = await asyncio.wait( - {init_task, closed_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - if init_task in done: - await init_task - - if closed_task in done: - raise RuntimeError(f"worker 组 {self.group_id} 在初始化阶段退出") - - self.handlers = list(self.peer.remote_handlers) - self.provided_capabilities = list(self.peer.remote_provided_capabilities) - metadata = dict(self.peer.remote_metadata) - remote_loaded_plugins = metadata.get("loaded_plugins") - if isinstance(remote_loaded_plugins, list): - self.loaded_plugins = [ - plugin_name - for plugin_name in remote_loaded_plugins - if isinstance(plugin_name, str) - ] - else: - self.loaded_plugins = [plugin.name for plugin in self.plugins] - remote_skipped_plugins = metadata.get("skipped_plugins") - if isinstance(remote_skipped_plugins, dict): - self.skipped_plugins = { - str(plugin_name): str(reason) - for plugin_name, reason in remote_skipped_plugins.items() - } - remote_capability_sources = metadata.get("capability_sources") - if isinstance(remote_capability_sources, dict): - self.capability_sources = { - str(capability_name): str(plugin_name) - for capability_name, plugin_name in remote_capability_sources.items() - } - remote_issues = metadata.get("issues") - if isinstance(remote_issues, list): - self.issues = [ - PluginDiscoveryIssue( - severity=str(item.get("severity", "error")), # type: ignore[arg-type] - phase=str(item.get("phase", "load")), # type: ignore[arg-type] - plugin_id=str(item.get("plugin_id", self.plugin.name)), - message=str(item.get("message", "")), - details=str(item.get("details", "")), - hint=str(item.get("hint", "")), - ) - for item in remote_issues - if isinstance(item, dict) - ] - remote_llm_tools = metadata.get("llm_tools") - if isinstance(remote_llm_tools, list): - self.llm_tools = [ - dict(item) for item in remote_llm_tools if isinstance(item, dict) - ] - remote_agents = metadata.get("agents") - if isinstance(remote_agents, list): - self.agents = [ - dict(item) for item in remote_agents if isinstance(item, dict) - ] - - except Exception: - await self.stop() - raise - - def _worker_command(self) -> tuple[Path, list[str], str]: - if self.group is not None: - prepare_group = getattr(self.env_manager, "prepare_group_environment", None) - if callable(prepare_group): - python_path = cast(Path, prepare_group(self.group)) - else: - python_path = self.env_manager.prepare_environment(self.plugins[0]) - return ( - python_path, - [ - str(python_path), - "-m", - "astrbot_sdk", - "worker", - "--group-metadata", - str(self.group.metadata_path), - ], - str(self.repo_root), - ) - - plugin = self.plugin - python_path = self.env_manager.prepare_environment(plugin) - return ( - python_path, - [ - str(python_path), - "-m", - "astrbot_sdk", - "worker", - "--plugin-dir", - str(plugin.plugin_dir), - ], - str(plugin.plugin_dir), - ) - - def start_close_watch(self) -> None: - if ( - self.on_closed is None - or self.peer is None - or self._connection_watch_task is not None - ): - return - self._connection_watch_task = asyncio.create_task(self._watch_connection()) - - async def _watch_connection(self) -> None: - """监听 Worker 连接关闭,触发清理回调""" - try: - if self.peer is not None: - await self.peer.wait_closed() - if self.on_closed is not None: - try: - self.on_closed() - except Exception: - logger.exception( - "on_closed callback failed for worker group {}", self.group_id - ) - finally: - current_task = asyncio.current_task() - if self._connection_watch_task is current_task: - self._connection_watch_task = None - - async def stop(self) -> None: - if self.peer is not None: - await self.peer.stop() - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, Any], - *, - request_id: str, - args: dict[str, Any] | None = None, - ) -> dict[str, Any]: - if self.peer is None: - raise RuntimeError("worker session is not running") - return await self.peer.invoke( - "handler.invoke", - { - "handler_id": handler_id, - "event": event_payload, - "args": dict(args or {}), - }, - request_id=request_id, - ) - - async def invoke_capability( - self, - capability_name: str, - payload: dict[str, Any], - *, - request_id: str, - ) -> dict[str, Any]: - if self.peer is None: - raise RuntimeError("worker session is not running") - return await self.peer.invoke( - capability_name, - payload, - request_id=request_id, - ) - - async def invoke_capability_stream( - self, - capability_name: str, - payload: dict[str, Any], - *, - request_id: str, - ): - if self.peer is None: - raise RuntimeError("worker session is not running") - event_stream = await self.peer.invoke_stream( - capability_name, - payload, - request_id=request_id, - include_completed=True, - ) - async for event in event_stream: - yield event - - async def cancel(self, request_id: str) -> None: - if self.peer is None: - return - await self.peer.cancel(request_id) - - async def _handle_initialize(self, _message) -> InitializeOutput: - return InitializeOutput( - peer=PeerInfo(name="astrbot-supervisor", role="core", version="v4"), - capabilities=self.capability_router.descriptors(), - metadata={ - "group_id": self.group_id, - "plugins": [plugin.name for plugin in self.plugins], - }, - ) - - async def _handle_capability_invoke(self, message, cancel_token): - return await self.capability_router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=cancel_token, - request_id=message.id, - ) - - def describe(self) -> dict[str, Any]: - return { - "group_id": self.group_id, - "plugins": [plugin.name for plugin in self.plugins], - "loaded_plugins": list(self.loaded_plugins), - "skipped_plugins": dict(self.skipped_plugins), - "issues": [issue.to_payload() for issue in self.issues], - } - - -class SupervisorRuntime: - def __init__( - self, - *, - transport, - plugins_dir: Path, - env_manager: PluginEnvironmentManager | None = None, - ) -> None: - self.transport = transport - self.plugins_dir = plugins_dir.resolve() - self.repo_root = Path(__file__).resolve().parents[3] - self.env_manager = env_manager or PluginEnvironmentManager(self.repo_root) - self.capability_router = CapabilityRouter() - self.peer = Peer( - transport=self.transport, - peer_info=PeerInfo(name="astrbot-supervisor", role="plugin", version="v4"), - ) - self.peer.set_invoke_handler(self._handle_upstream_invoke) - self.peer.set_cancel_handler(self._handle_upstream_cancel) - self.worker_sessions: dict[str, WorkerSession] = {} - self.handler_to_worker: dict[str, WorkerSession] = {} - self.capability_to_worker: dict[str, WorkerSession] = {} - self.plugin_to_worker_session: dict[str, WorkerSession] = {} - self._handler_sources: dict[str, str] = {} # handler_id -> plugin_name - self._capability_sources: dict[str, str] = {} # capability_name -> plugin_name - self.active_requests: dict[str, WorkerSession] = {} - self.loaded_plugins: list[str] = [] - self.skipped_plugins: dict[str, str] = {} - self.issues: list[PluginDiscoveryIssue] = [] - self._register_internal_capabilities() - - def _publish_plugin_registry_snapshot( - self, - plugins: list[PluginSpec], - *, - enabled_plugins: set[str], - ) -> None: - for plugin in plugins: - manifest = plugin.manifest_data - self.capability_router.upsert_plugin( - metadata={ - "name": plugin.name, - "display_name": str(manifest.get("display_name") or plugin.name), - "description": str( - manifest.get("desc") or manifest.get("description") or "" - ), - "author": str(manifest.get("author") or ""), - "version": str(manifest.get("version") or "0.0.0"), - "enabled": plugin.name in enabled_plugins, - }, - config=load_plugin_config(plugin), - ) - - def _publish_discovered_plugin_registry(self, plugins: list[PluginSpec]) -> None: - """发布已发现插件的静态元数据。 - - 这一阶段发生在 worker 真正启动前。此时 supervisor 已经知道有哪些插件、 - 它们的 manifest/config 是什么,但尚未确认哪些插件实际完成加载,因此统一 - 以 `enabled=False` 暴露给 metadata 能力。 - """ - self._publish_plugin_registry_snapshot(plugins, enabled_plugins=set()) - - def _publish_loaded_plugin_registry(self, plugins: list[PluginSpec]) -> None: - """在 worker 启动完成后刷新插件启用状态。""" - self._publish_plugin_registry_snapshot( - plugins, - enabled_plugins=set(self.loaded_plugins), - ) - - def _register_internal_capabilities(self) -> None: - self.capability_router.register( - CapabilityDescriptor( - name="handler.invoke", - description="框架内部:转发到插件 handler", - input_schema={ - "type": "object", - "properties": { - "handler_id": {"type": "string"}, - "event": {"type": "object"}, - }, - "required": ["handler_id", "event"], - }, - output_schema={ - "type": "object", - "properties": {}, - "required": [], - }, - cancelable=True, - ), - call_handler=self._route_handler_invoke, - exposed=False, - ) - - def _register_handler( - self, handler, session: WorkerSession, plugin_name: str - ) -> None: - """注册 handler,处理冲突时输出警告。 - - Args: - handler: Handler 描述符 - session: Worker 会话 - plugin_name: 插件名称 - """ - handler_id = handler.id - existing_plugin = self._handler_sources.get(handler_id) - - if existing_plugin is not None: - logger.warning( - f"Handler ID 冲突:'{handler_id}' 已被插件 '{existing_plugin}' 注册," - f"现在被插件 '{plugin_name}' 覆盖。" - ) - - self.handler_to_worker[handler_id] = session - self._handler_sources[handler_id] = plugin_name - - def _register_plugin_capability( - self, - descriptor: CapabilityDescriptor, - session: WorkerSession, - plugin_name: str, - ) -> None: - """注册插件 capability,处理命名冲突。 - - 当 capability 名称冲突时: - - 如果是保留命名空间(handler/system/internal),跳过并警告 - - 否则,使用插件名作为前缀重新命名,例如: - - 插件 'my_plugin' 注册 'demo.echo' 冲突 - - 自动重命名为 'my_plugin.demo.echo' - """ - capability_name = descriptor.name - - if not self.capability_router.contains(capability_name): - # 无冲突,直接注册 - self._do_register_capability( - descriptor, session, capability_name, plugin_name - ) - return - - # 检查是否在保留命名空间内 - if capability_name.startswith(("handler.", "system.", "internal.")): - logger.warning( - "Capability '{}' 在保留命名空间内,跳过插件 '{}' 的注册。" - "保留命名空间不允许插件覆盖。", - capability_name, - plugin_name, - ) - return - - # 尝试添加插件前缀解决冲突 - prefixed_name = f"{plugin_name}.{capability_name}" - if self.capability_router.contains(prefixed_name): - logger.warning( - "Capability '{}' 和 '{}.{}' 均已存在," - "跳过插件 '{}' 的注册。请考虑使用更唯一的命名。", - capability_name, - plugin_name, - capability_name, - plugin_name, - ) - return - - # 使用前缀名称注册 - prefixed_descriptor = descriptor.model_copy(deep=True) - prefixed_descriptor.name = prefixed_name - logger.info( - "Capability '{}' 与已注册能力冲突,自动重命名为 '{}' (插件: {})。", - capability_name, - prefixed_name, - plugin_name, - ) - self._do_register_capability( - prefixed_descriptor, session, prefixed_name, plugin_name - ) - # 记录原始名称到前缀名称的映射,便于调试 - self._capability_sources[f"_original:{prefixed_name}"] = capability_name - - def _do_register_capability( - self, - descriptor: CapabilityDescriptor, - session: WorkerSession, - capability_name: str, - plugin_name: str, - ) -> None: - """实际执行 capability 注册。""" - self.capability_router.register( - descriptor, - call_handler=self._make_plugin_capability_caller(session, capability_name), - stream_handler=( - self._make_plugin_capability_streamer(session, capability_name) - if descriptor.supports_stream - else None - ), - ) - self.capability_to_worker[capability_name] = session - self._capability_sources[capability_name] = plugin_name - - def _make_plugin_capability_caller( - self, - session: WorkerSession, - capability_name: str, - ): - async def call_handler( - request_id: str, - payload: dict[str, Any], - _cancel_token, - ) -> dict[str, Any]: - self.active_requests[request_id] = session - try: - return await session.invoke_capability( - capability_name, - payload, - request_id=request_id, - ) - finally: - self.active_requests.pop(request_id, None) - - return call_handler - - def _make_plugin_capability_streamer( - self, - session: WorkerSession, - capability_name: str, - ): - async def stream_handler( - request_id: str, - payload: dict[str, Any], - _cancel_token, - ): - completed_output: dict[str, Any] = {} - - async def iterator(): - self.active_requests[request_id] = session - try: - async for event in session.invoke_capability_stream( - capability_name, - payload, - request_id=request_id, - ): - if not isinstance(event, EventMessage): - raise AstrBotError.protocol_error( - "插件 worker 返回了非法的流式事件" - ) - if event.phase == "delta": - yield event.data or {} - continue - if event.phase == "completed": - completed_output.clear() - completed_output.update(event.output or {}) - finally: - self.active_requests.pop(request_id, None) - - return StreamExecution( - iterator=iterator(), - finalize=lambda chunks: completed_output or {"items": chunks}, - ) - - return stream_handler - - async def start(self) -> None: - discovery = discover_plugins(self.plugins_dir) - self.skipped_plugins = dict(discovery.skipped_plugins) - self.issues = list(discovery.issues) - plan_result = self.env_manager.plan(discovery.plugins) - self.skipped_plugins.update(plan_result.skipped_plugins) - self.issues.extend( - PluginDiscoveryIssue( - severity="error", - phase="load", - plugin_id=plugin_name, - message="插件环境规划失败", - details=str(reason), - ) - for plugin_name, reason in plan_result.skipped_plugins.items() - ) - # 先发布静态插件元数据,允许 supervisor 侧在 worker 启动阶段就读取配置/清单。 - self._publish_discovered_plugin_registry(discovery.plugins) - try: - planned_sessions: list[WorkerSession] = [] - if plan_result.groups: - for group in plan_result.groups: - planned_sessions.append( - WorkerSession( - group=group, - repo_root=self.repo_root, - env_manager=self.env_manager, - capability_router=self.capability_router, - on_closed=lambda group_id=group.id: ( - self._handle_worker_closed(group_id) - ), - ) - ) - else: - for plugin in plan_result.plugins: - planned_sessions.append( - WorkerSession( - plugin=plugin, - repo_root=self.repo_root, - env_manager=self.env_manager, - capability_router=self.capability_router, - on_closed=lambda plugin_name=plugin.name: ( - self._handle_worker_closed(plugin_name) - ), - ) - ) - - for session in planned_sessions: - try: - await session.start() - except Exception as exc: - for plugin in session.plugins: - self.skipped_plugins[plugin.name] = str(exc) - self.issues.append( - PluginDiscoveryIssue( - severity="error", - phase="load", - plugin_id=plugin.name, - message="插件 worker 启动失败", - details=str(exc), - ) - ) - await session.stop() - continue - self.worker_sessions[session.group_id] = session - self.skipped_plugins.update(session.skipped_plugins) - self.issues.extend(session.issues) - for plugin_name in session.loaded_plugins: - self.plugin_to_worker_session[plugin_name] = session - if plugin_name not in self.loaded_plugins: - self.loaded_plugins.append(plugin_name) - for handler in session.handlers: - self._register_handler( - handler, - session, - _plugin_name_from_handler_id(handler.id), - ) - for descriptor in session.provided_capabilities: - plugin_name = session.capability_sources.get(descriptor.name) - if plugin_name is None and len(session.loaded_plugins) == 1: - plugin_name = session.loaded_plugins[0] - if plugin_name is None: - plugin_name = session.group_id - self._register_plugin_capability(descriptor, session, plugin_name) - session.start_close_watch() - - # worker 启动后再用实际加载结果刷新 enabled 状态,形成显式两阶段发布。 - self._publish_loaded_plugin_registry(discovery.plugins) - - aggregated_handlers = list(self.handler_to_worker.keys()) - logger.info( - "Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none" - ) - - await self.peer.start() - await self.peer.initialize( - [ - handler - for session in self.worker_sessions.values() - for handler in session.handlers - ], - provided_capabilities=self.capability_router.descriptors(), - metadata={ - "plugins": sorted(self.loaded_plugins), - "skipped_plugins": self.skipped_plugins, - "issues": [issue.to_payload() for issue in self.issues], - "aggregated_handler_ids": aggregated_handlers, - "worker_groups": [ - session.describe() for session in self.worker_sessions.values() - ], - "worker_group_count": len(self.worker_sessions), - }, - ) - except Exception: - await self.stop() - raise - - def _handle_worker_closed(self, group_id: str) -> None: - """Worker 连接关闭时的清理回调""" - session = self.worker_sessions.pop(group_id, None) - if session is None: - return - # 从 handler_to_worker 中移除该插件注册的 handlers(仅当来源仍为此插件时) - for handler in session.handlers: - source_plugin = self._handler_sources.get(handler.id) - if source_plugin == _plugin_name_from_handler_id(handler.id) or ( - source_plugin == group_id - ): - self.handler_to_worker.pop(handler.id, None) - self._handler_sources.pop(handler.id, None) - for descriptor in session.provided_capabilities: - source_plugin = self._capability_sources.get(descriptor.name) - capability_plugin = session.capability_sources.get(descriptor.name) - if source_plugin == capability_plugin or ( - capability_plugin is None - and ( - source_plugin == group_id or source_plugin in session.loaded_plugins - ) - ): - self.capability_to_worker.pop(descriptor.name, None) - self._capability_sources.pop(descriptor.name, None) - self.capability_router.unregister(descriptor.name) - session_loaded_plugins = getattr(session, "loaded_plugins", None) - if not isinstance(session_loaded_plugins, list): - session_loaded_plugins = [group_id] - for plugin_name in session_loaded_plugins: - if plugin_name in self.loaded_plugins: - self.loaded_plugins.remove(plugin_name) - self.plugin_to_worker_session.pop(plugin_name, None) - self.capability_router.set_plugin_enabled(plugin_name, False) - self.capability_router.remove_http_apis_for_plugin(plugin_name) - stale_requests = [ - request_id - for request_id, active_session in self.active_requests.items() - if active_session is session - ] - for request_id in stale_requests: - self.active_requests.pop(request_id, None) - logger.warning("worker 组 {} 连接已关闭,已清理相关 handlers", group_id) - - async def stop(self) -> None: - for session in list(self.worker_sessions.values()): - await session.stop() - await self.peer.stop() - - async def _handle_upstream_invoke(self, message, cancel_token): - return await self.capability_router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=cancel_token, - request_id=message.id, - ) - - async def _route_handler_invoke( - self, - request_id: str, - payload: dict[str, Any], - _cancel_token, - ) -> dict[str, Any]: - handler_id = str(payload.get("handler_id", "")) - session = self.handler_to_worker.get(handler_id) - if session is None: - raise AstrBotError.invalid_input(f"handler not found: {handler_id}") - self.active_requests[request_id] = session - try: - return await session.invoke_handler( - handler_id, - payload.get("event", {}), - request_id=request_id, - args=payload.get("args", {}), - ) - finally: - self.active_requests.pop(request_id, None) - - async def _handle_upstream_cancel(self, request_id: str) -> None: - session = self.active_requests.get(request_id) - if session is not None: - await session.cancel(request_id) diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/transport.py b/astrbot-sdk/src/astrbot_sdk/runtime/transport.py deleted file mode 100644 index d4c55cdca..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/transport.py +++ /dev/null @@ -1,403 +0,0 @@ -"""传输层抽象模块。 - -定义 Transport 抽象基类及其实现,负责底层的消息传输。 -传输层只关心"发送字符串"和"接收字符串",不处理协议细节。 -传输实现: - Transport: 抽象基类,定义 start/stop/send/wait_closed 接口 - StdioTransport: 标准输入输出传输 - - 进程模式: 通过 command 参数启动子进程 - - 文件模式: 通过 stdin/stdout 参数指定文件描述符 - -传输类型: - Transport: 抽象基类,定义 start/stop/send 接口 - StdioTransport: 标准输入输出传输,支持进程模式和文件模式 - WebSocketServerTransport: WebSocket 服务端传输 - - 单连接限制,支持心跳配置 - - 通过 port 属性获取实际监听端口 - - 自动重连需要外部实现 - -使用示例: - # 子进程模式 - transport = StdioTransport( - command=["python", "-m", "my_plugin"], - cwd="/path/to/plugin", - ) - - # 标准输入输出模式 - transport = StdioTransport(stdin=sys.stdin, stdout=sys.stdout) - - # WebSocket 服务端 - transport = WebSocketServerTransport(host="0.0.0.0", port=8765) - - # WebSocket 客户端 - transport = WebSocketClientTransport(url="ws://localhost:8765") - - # 统一接口 - transport.set_message_handler(my_handler) - await transport.start() - await transport.send(json_string) - await transport.stop() - -`Transport` 只处理“字符串发出去 / 字符串收进来”这件事,不做协议解析,也不关心 -能力、handler 或迁移适配策略。当前实现包括: - -- `StdioTransport`: 子进程或文件对象上的按行文本传输 -- `WebSocketServerTransport`: 单连接 WebSocket 服务端 -- `WebSocketClientTransport`: WebSocket 客户端 - -自动重连、消息重放等策略不在这里实现,统一留给更上层编排。 -""" - -from __future__ import annotations - -import asyncio -import sys -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable, Sequence -from typing import IO, Any - -from loguru import logger - -MessageHandler = Callable[[str], Awaitable[None]] - - -def _get_aiohttp(): - import aiohttp - - return aiohttp - - -def _get_web(): - from aiohttp import web - - return web - - -def _frame_stdio_payload(payload: str) -> str: - body = payload - if body.endswith("\r\n"): - body = body[:-2] - elif body.endswith(("\n", "\r")): - body = body[:-1] - if "\n" in body or "\r" in body: - raise ValueError("STDIO payload 不允许包含原始换行符") - return f"{body}\n" - - -# TODO 一个更好的解决方案? -def _is_windows_access_denied(error: BaseException) -> bool: - return ( - sys.platform == "win32" - and isinstance(error, PermissionError) - and getattr(error, "winerror", None) == 5 - ) - - -class Transport(ABC): - def __init__(self) -> None: - self._handler: MessageHandler | None = None - self._closed = asyncio.Event() - - def set_message_handler(self, handler: MessageHandler) -> None: - """注册收到原始字符串消息后的回调。""" - self._handler = handler - - @abstractmethod - async def start(self) -> None: - raise NotImplementedError - - @abstractmethod - async def stop(self) -> None: - raise NotImplementedError - - @abstractmethod - async def send(self, payload: str) -> None: - raise NotImplementedError - - async def wait_closed(self) -> None: - """等待传输层进入关闭状态。""" - await self._closed.wait() - - async def _dispatch(self, payload: str) -> None: - """把收到的原始载荷转交给上层处理器。""" - if self._handler is not None: - await self._handler(payload) - - -class StdioTransport(Transport): - def __init__( - self, - *, - stdin: IO[str] | None = None, - stdout: IO[str] | None = None, - command: Sequence[str] | None = None, - cwd: str | None = None, - env: dict[str, str] | None = None, - ) -> None: - super().__init__() - self._stdin = stdin - self._stdout = stdout - self._command = list(command) if command is not None else None - self._cwd = cwd - self._env = env - self._process: asyncio.subprocess.Process | None = None - self._reader_task: asyncio.Task[None] | None = None - - async def start(self) -> None: - self._closed.clear() - if self._command is not None: - self._process = await self._start_subprocess_with_retry() - self._reader_task = asyncio.create_task(self._read_process_loop()) - return - - self._stdin = self._stdin or sys.stdin - self._stdout = self._stdout or sys.stdout - self._reader_task = asyncio.create_task(self._read_file_loop()) - - async def _start_subprocess_with_retry(self) -> asyncio.subprocess.Process: - assert self._command is not None # 类型收窄:start() 已确保非空 - delays = [0.15, 0.35, 0.75] - last_error: BaseException | None = None - for attempt, delay in enumerate([0.0, *delays], start=1): - if delay: - await asyncio.sleep(delay) - try: - return await asyncio.create_subprocess_exec( - *self._command, - cwd=self._cwd, - env=self._env, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=sys.stderr, - ) - except Exception as exc: - last_error = exc - if not _is_windows_access_denied(exc) or attempt == len(delays) + 1: - raise - logger.warning( - "Windows denied access while starting freshly prepared worker " - "interpreter, retrying attempt {}/{}: {}", - attempt, - len(delays) + 1, - exc, - ) - assert last_error is not None - raise last_error - - async def stop(self) -> None: - if self._reader_task is not None: - self._reader_task.cancel() - try: - await self._reader_task - except asyncio.CancelledError: - pass - self._reader_task = None - - if self._process is not None: - if self._process.returncode is None: - self._process.terminate() - try: - await asyncio.wait_for(self._process.wait(), timeout=5) - except asyncio.TimeoutError: - self._process.kill() - await self._process.wait() - self._process = None - self._closed.set() - - async def send(self, payload: str) -> None: - line = _frame_stdio_payload(payload) - if self._process is not None: - if self._process.stdin is None: - raise RuntimeError("STDIO subprocess stdin 不可用") - self._process.stdin.write(line.encode("utf-8")) - await self._process.stdin.drain() - return - - if self._stdout is None: - raise RuntimeError("STDIO stdout 不可用") - - def _write() -> None: - assert self._stdout is not None - self._stdout.write(line) - self._stdout.flush() - - await asyncio.to_thread(_write) - - async def _read_process_loop(self) -> None: - assert self._process is not None - assert self._process.stdout is not None - try: - while True: - raw = await self._process.stdout.readline() - if not raw: - break - await self._dispatch(raw.decode("utf-8").rstrip("\r\n")) - finally: - self._closed.set() - - async def _read_file_loop(self) -> None: - assert self._stdin is not None - try: - while True: - raw = await asyncio.to_thread(self._stdin.readline) - if not raw: - break - await self._dispatch(raw.rstrip("\r\n")) - finally: - self._closed.set() - - -class WebSocketServerTransport(Transport): - def __init__( - self, - *, - host: str = "127.0.0.1", - port: int = 8765, - path: str = "/", - heartbeat: float = 30.0, - ) -> None: - super().__init__() - self._host = host - self._port = port - self._actual_port: int | None = None - self._path = path - self._heartbeat = heartbeat - self._app: Any | None = None - self._runner: Any | None = None - self._site: Any | None = None - self._ws: Any | None = None - self._write_lock = asyncio.Lock() - self._connected = asyncio.Event() - - async def start(self) -> None: - web = _get_web() - self._closed.clear() - self._connected.clear() - self._app = web.Application() - self._app.router.add_get(self._path, self._handle_socket) - self._runner = web.AppRunner(self._app) - await self._runner.setup() - self._site = web.TCPSite(self._runner, self._host, self._port) - await self._site.start() - if self._site._server and getattr(self._site._server, "sockets", None): - socket = self._site._server.sockets[0] - self._actual_port = socket.getsockname()[1] - - async def stop(self) -> None: - self._connected.clear() - if self._ws is not None and not self._ws.closed: - await self._ws.close() - if self._site is not None: - await self._site.stop() - self._site = None - if self._runner is not None: - await self._runner.cleanup() - self._runner = None - self._closed.set() - - async def send(self, payload: str) -> None: - if self._ws is None or self._ws.closed: - await asyncio.wait_for(self._connected.wait(), timeout=30.0) - if self._ws is None or self._ws.closed: - raise RuntimeError("WebSocket 尚未连接") - async with self._write_lock: - await self._ws.send_str(payload) - - async def _handle_socket(self, request) -> Any: - web = _get_web() - aiohttp = _get_aiohttp() - if self._ws is not None and not self._ws.closed: - ws = web.WebSocketResponse() - await ws.prepare(request) - await ws.close(code=1008, message=b"only one websocket connection allowed") - return ws - - ws = web.WebSocketResponse( - heartbeat=self._heartbeat if self._heartbeat > 0 else None - ) - await ws.prepare(request) - self._ws = ws - self._connected.set() - try: - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - await self._dispatch(msg.data) - elif msg.type == aiohttp.WSMsgType.BINARY: - await self._dispatch(msg.data.decode("utf-8")) - elif msg.type == aiohttp.WSMsgType.ERROR: - logger.error("websocket server error: {}", ws.exception()) - break - finally: - self._connected.clear() - self._closed.set() - self._ws = None - return ws - - @property - def port(self) -> int: - return self._actual_port or self._port - - @property - def url(self) -> str: - return f"ws://{self._host}:{self.port}{self._path}" - - -class WebSocketClientTransport(Transport): - def __init__( - self, - *, - url: str, - heartbeat: float = 30.0, - ) -> None: - super().__init__() - self._url = url - self._heartbeat = heartbeat - self._session: Any | None = None - self._ws: Any | None = None - self._reader_task: asyncio.Task[None] | None = None - - async def start(self) -> None: - aiohttp = _get_aiohttp() - self._closed.clear() - self._session = aiohttp.ClientSession() - self._ws = await self._session.ws_connect( - self._url, - heartbeat=self._heartbeat if self._heartbeat > 0 else None, - ) - self._reader_task = asyncio.create_task(self._read_loop()) - - async def stop(self) -> None: - if self._reader_task is not None: - self._reader_task.cancel() - try: - await self._reader_task - except asyncio.CancelledError: - pass - self._reader_task = None - if self._ws is not None and not self._ws.closed: - await self._ws.close() - if self._session is not None: - await self._session.close() - self._ws = None - self._session = None - self._closed.set() - - async def send(self, payload: str) -> None: - if self._ws is None or self._ws.closed: - raise RuntimeError("WebSocket client 尚未连接") - await self._ws.send_str(payload) - - async def _read_loop(self) -> None: - assert self._ws is not None - aiohttp = _get_aiohttp() - try: - async for msg in self._ws: - if msg.type == aiohttp.WSMsgType.TEXT: - await self._dispatch(msg.data) - elif msg.type == aiohttp.WSMsgType.BINARY: - await self._dispatch(msg.data.decode("utf-8")) - elif msg.type == aiohttp.WSMsgType.ERROR: - logger.error("websocket client error: {}", self._ws.exception()) - break - finally: - self._closed.set() diff --git a/astrbot-sdk/src/astrbot_sdk/runtime/worker.py b/astrbot-sdk/src/astrbot_sdk/runtime/worker.py deleted file mode 100644 index f88e5e8f2..000000000 --- a/astrbot-sdk/src/astrbot_sdk/runtime/worker.py +++ /dev/null @@ -1,445 +0,0 @@ -"""Worker 端运行时:PluginWorkerRuntime 运行单个插件,GroupWorkerRuntime 在同一进程中运行多个插件。 - -核心类: - GroupWorkerRuntime: 组 Worker 运行时 - - 在同一进程中加载并运行多个插件 - - 聚合所有插件的 handlers 和 capabilities - - 统一处理 invoke 和 cancel 请求 - - 管理每个插件的生命周期回调 - - PluginWorkerRuntime: 单插件 Worker 运行时 - - 加载单个插件 - - 通过 Peer 与 Supervisor 通信 - - 分发 handler 调用 - - 处理生命周期回调 (on_start, on_stop) - -启动流程: - Worker 启动: - 1. load_plugin_spec() 加载插件规范 - 2. load_plugin() 加载插件组件 - 3. 创建 Peer 并设置处理器 - 4. 向 Supervisor 发送 initialize - 5. 等待 Supervisor 的 initialize_result - 6. 执行 on_start 生命周期回调 -""" - -from __future__ import annotations - -import inspect -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from loguru import logger - -from .._internal.invocation_context import caller_plugin_scope -from .._internal.star_runtime import bind_star_runtime -from ..context import Context as RuntimeContext -from ..errors import AstrBotError -from ..protocol.messages import PeerInfo -from ..star import Star -from .handler_dispatcher import CapabilityDispatcher, HandlerDispatcher -from .loader import ( - LoadedPlugin, - PluginDiscoveryIssue, - PluginSpec, - load_plugin, - load_plugin_spec, -) -from .peer import Peer - -__all__ = [ - "GroupPluginRuntimeState", - "GroupWorkerRuntime", - "PluginWorkerRuntime", - "_load_group_plugin_specs", -] - -GLOBAL_MCP_RISK_ATTR = "__astrbot_acknowledge_global_mcp_risk__" - - -@dataclass(slots=True) -class GroupPluginRuntimeState: - plugin: PluginSpec - loaded_plugin: LoadedPlugin - lifecycle_context: RuntimeContext - - -def _plugin_acknowledges_global_mcp_risk(instances: list[Any]) -> bool: - return any( - bool(getattr(instance.__class__, GLOBAL_MCP_RISK_ATTR, False)) - for instance in instances - ) - - -def _load_group_plugin_specs(group_metadata_path: Path) -> tuple[str, list[PluginSpec]]: - try: - payload = json.loads(group_metadata_path.read_text(encoding="utf-8")) - except Exception as exc: - raise RuntimeError( - f"failed to read worker group metadata: {group_metadata_path}" - ) from exc - - if not isinstance(payload, dict): - raise RuntimeError(f"invalid worker group metadata: {group_metadata_path}") - - entries = payload.get("plugin_entries") - if not isinstance(entries, list) or not entries: - raise RuntimeError( - f"worker group metadata missing plugin_entries: {group_metadata_path}" - ) - - plugins: list[PluginSpec] = [] - for entry in entries: - if not isinstance(entry, dict): - raise RuntimeError( - f"worker group metadata contains invalid plugin entry: {group_metadata_path}" - ) - plugin_dir = entry.get("plugin_dir") - if not isinstance(plugin_dir, str) or not plugin_dir: - raise RuntimeError( - f"worker group metadata contains invalid plugin_dir: {group_metadata_path}" - ) - plugins.append(load_plugin_spec(Path(plugin_dir))) - - group_id = payload.get("group_id") - if not isinstance(group_id, str) or not group_id: - group_id = group_metadata_path.stem - return group_id, plugins - - -async def run_plugin_lifecycle( - instances: list[Any], - method_name: str, - context: RuntimeContext, -) -> None: - """运行插件生命周期方法。""" - for instance in instances: - method = getattr(instance, method_name, None) - if method is None: - continue - with caller_plugin_scope(context.plugin_id): - owner = instance if isinstance(instance, Star) else None - with bind_star_runtime(owner, context): - result = method(context) - if inspect.isawaitable(result): - await result - - -class GroupWorkerRuntime: - def __init__(self, *, group_metadata_path: Path, transport) -> None: - self.group_metadata_path = group_metadata_path.resolve() - self.group_id, self.plugins = _load_group_plugin_specs(self.group_metadata_path) - self.transport = transport - self.peer = Peer( - transport=self.transport, - peer_info=PeerInfo(name=self.group_id, role="plugin", version="v4"), - ) - self.skipped_plugins: dict[str, str] = {} - self.issues: list[PluginDiscoveryIssue] = [] - self._plugin_states: list[GroupPluginRuntimeState] = [] - self._active_plugin_states: list[GroupPluginRuntimeState] = [] - self._load_plugins() - self._refresh_dispatchers() - self.peer.set_invoke_handler(self._handle_invoke) - self.peer.set_cancel_handler(self._handle_cancel) - - def _load_plugins(self) -> None: - for plugin in self.plugins: - try: - loaded_plugin = load_plugin(plugin) - except Exception as exc: - self.skipped_plugins[plugin.name] = str(exc) - self.issues.append( - PluginDiscoveryIssue( - severity="error", - phase="load", - plugin_id=plugin.name, - message="插件加载失败", - details=str(exc), - ) - ) - logger.exception( - "组 {} 中插件 {} 加载失败,启动时将跳过", - self.group_id, - plugin.name, - ) - continue - - lifecycle_context = RuntimeContext(peer=self.peer, plugin_id=plugin.name) - self._plugin_states.append( - GroupPluginRuntimeState( - plugin=plugin, - loaded_plugin=loaded_plugin, - lifecycle_context=lifecycle_context, - ) - ) - self._active_plugin_states = list(self._plugin_states) - - def _refresh_dispatchers(self) -> None: - handlers = [ - handler - for state in self._active_plugin_states - for handler in state.loaded_plugin.handlers - ] - capabilities = [ - capability - for state in self._active_plugin_states - for capability in state.loaded_plugin.capabilities - ] - self.dispatcher = HandlerDispatcher( - plugin_id=self.group_id, - peer=self.peer, - handlers=handlers, - ) - self.capability_dispatcher = CapabilityDispatcher( - plugin_id=self.group_id, - peer=self.peer, - capabilities=capabilities, - llm_tools=[ - tool - for state in self._active_plugin_states - for tool in state.loaded_plugin.llm_tools - ], - ) - - async def start(self) -> None: - await self.peer.start() - started_states: list[GroupPluginRuntimeState] = [] - try: - active_states: list[GroupPluginRuntimeState] = [] - for state in self._plugin_states: - try: - await self._run_lifecycle(state, "on_start") - except Exception as exc: - self.skipped_plugins[state.plugin.name] = str(exc) - self.issues.append( - PluginDiscoveryIssue( - severity="error", - phase="lifecycle", - plugin_id=state.plugin.name, - message="插件 on_start 失败", - details=str(exc), - ) - ) - logger.exception( - "组 {} 中插件 {} on_start 失败,启动时将跳过", - self.group_id, - state.plugin.name, - ) - continue - active_states.append(state) - started_states.append(state) - - self._active_plugin_states = active_states - self._refresh_dispatchers() - if not self._active_plugin_states: - raise RuntimeError( - f"worker group {self.group_id} has no active plugins" - ) - - await self.peer.initialize( - [ - handler.descriptor - for state in self._active_plugin_states - for handler in state.loaded_plugin.handlers - ], - provided_capabilities=[ - capability.descriptor - for state in self._active_plugin_states - for capability in state.loaded_plugin.capabilities - ], - metadata=self._initialize_metadata(), - ) - except Exception: - for state in reversed(started_states): - try: - await self._run_lifecycle(state, "on_stop") - except Exception: - logger.exception( - "组 {} 在启动失败清理插件 {} on_stop 时发生异常", - self.group_id, - state.plugin.name, - ) - await self.peer.stop() - raise - - async def stop(self) -> None: - first_error: Exception | None = None - try: - for state in reversed(self._active_plugin_states): - try: - await self._run_lifecycle(state, "on_stop") - except Exception as exc: - if first_error is None: - first_error = exc - logger.exception( - "组 {} 停止插件 {} 时发生异常", - self.group_id, - state.plugin.name, - ) - finally: - await self.peer.stop() - if first_error is not None: - raise first_error - - async def _handle_invoke(self, message, cancel_token): - if message.capability == "handler.invoke": - return await self.dispatcher.invoke(message, cancel_token) - try: - return await self.capability_dispatcher.invoke(message, cancel_token) - except LookupError as exc: - raise AstrBotError.capability_not_found(message.capability) from exc - - async def _handle_cancel(self, request_id: str) -> None: - await self.dispatcher.cancel(request_id) - await self.capability_dispatcher.cancel(request_id) - - def _initialize_metadata(self) -> dict[str, Any]: - return { - "group_id": self.group_id, - "plugins": [plugin.name for plugin in self.plugins], - "loaded_plugins": [ - state.plugin.name for state in self._active_plugin_states - ], - "skipped_plugins": dict(self.skipped_plugins), - "capability_sources": { - capability.descriptor.name: state.plugin.name - for state in self._active_plugin_states - for capability in state.loaded_plugin.capabilities - }, - "issues": [issue.to_payload() for issue in self.issues], - "llm_tools": [ - { - **tool.spec.to_payload(), - "plugin_id": state.plugin.name, - } - for state in self._active_plugin_states - for tool in state.loaded_plugin.llm_tools - ], - "agents": [ - { - **agent.spec.to_payload(), - "plugin_id": state.plugin.name, - } - for state in self._active_plugin_states - for agent in state.loaded_plugin.agents - ], - "acknowledge_global_mcp_risk": any( - _plugin_acknowledges_global_mcp_risk(state.loaded_plugin.instances) - for state in self._active_plugin_states - ), - } - - async def _run_lifecycle( - self, - state: GroupPluginRuntimeState, - method_name: str, - ) -> None: - await run_plugin_lifecycle( - state.loaded_plugin.instances, method_name, state.lifecycle_context - ) - - -class PluginWorkerRuntime: - def __init__(self, *, plugin_dir: Path, transport) -> None: - self.plugin = load_plugin_spec(plugin_dir) - self.transport = transport - self.loaded_plugin = load_plugin(self.plugin) - self.peer = Peer( - transport=self.transport, - peer_info=PeerInfo(name=self.plugin.name, role="plugin", version="v4"), - ) - self.dispatcher = HandlerDispatcher( - plugin_id=self.plugin.name, - peer=self.peer, - handlers=self.loaded_plugin.handlers, - ) - self.capability_dispatcher = CapabilityDispatcher( - plugin_id=self.plugin.name, - peer=self.peer, - capabilities=self.loaded_plugin.capabilities, - llm_tools=self.loaded_plugin.llm_tools, - ) - self._lifecycle_context = RuntimeContext( - peer=self.peer, plugin_id=self.plugin.name - ) - self.issues: list[PluginDiscoveryIssue] = [] - self.peer.set_invoke_handler(self._handle_invoke) - self.peer.set_cancel_handler(self._handle_cancel) - - async def start(self) -> None: - await self.peer.start() - lifecycle_started = False - try: - await self._run_lifecycle("on_start") - lifecycle_started = True - await self.peer.initialize( - [item.descriptor for item in self.loaded_plugin.handlers], - provided_capabilities=[ - item.descriptor for item in self.loaded_plugin.capabilities - ], - metadata={ - "plugin_id": self.plugin.name, - "plugins": [self.plugin.name], - "loaded_plugins": [self.plugin.name], - "skipped_plugins": {}, - "issues": [issue.to_payload() for issue in self.issues], - "capability_sources": { - item.descriptor.name: self.plugin.name - for item in self.loaded_plugin.capabilities - }, - "llm_tools": [ - { - **item.spec.to_payload(), - "plugin_id": self.plugin.name, - } - for item in self.loaded_plugin.llm_tools - ], - "agents": [ - { - **item.spec.to_payload(), - "plugin_id": self.plugin.name, - } - for item in self.loaded_plugin.agents - ], - "acknowledge_global_mcp_risk": _plugin_acknowledges_global_mcp_risk( - self.loaded_plugin.instances - ), - }, - ) - except Exception: - if lifecycle_started: - try: - await self._run_lifecycle("on_stop") - except Exception: - logger.exception( - "插件 {} 在启动失败清理 on_stop 时发生异常", - self.plugin.name, - ) - await self.peer.stop() - raise - - async def stop(self) -> None: - try: - await self._run_lifecycle("on_stop") - finally: - await self.peer.stop() - - async def _handle_invoke(self, message, cancel_token): - if message.capability == "handler.invoke": - return await self.dispatcher.invoke(message, cancel_token) - try: - return await self.capability_dispatcher.invoke(message, cancel_token) - except LookupError as exc: - raise AstrBotError.capability_not_found(message.capability) from exc - - async def _handle_cancel(self, request_id: str) -> None: - await self.dispatcher.cancel(request_id) - await self.capability_dispatcher.cancel(request_id) - - async def _run_lifecycle(self, method_name: str) -> None: - await run_plugin_lifecycle( - self.loaded_plugin.instances, method_name, self._lifecycle_context - ) diff --git a/astrbot-sdk/src/astrbot_sdk/schedule.py b/astrbot-sdk/src/astrbot_sdk/schedule.py deleted file mode 100644 index e0aa20c7a..000000000 --- a/astrbot-sdk/src/astrbot_sdk/schedule.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Schedule-specific SDK types. - -本模块定义定时任务相关的 SDK 类型,主要为 ScheduleContext 提供数据结构。 - -ScheduleContext 包含: -- schedule_id: 调度任务唯一标识 -- plugin_id: 所属插件 ID -- handler_id: 对应 handler 的标识 -- trigger_kind: 触发类型(cron / interval / once) -- cron: cron 表达式(仅 cron 类型) -- interval_seconds: 间隔秒数(仅 interval 类型) -- scheduled_at: 计划执行时间(仅 once 类型) - -使用方式: -通过 @on_schedule 装饰器注册的 handler 可通过参数注入获取 ScheduleContext。 -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass(slots=True) -class ScheduleContext: - schedule_id: str - plugin_id: str - handler_id: str - trigger_kind: str - cron: str | None = None - interval_seconds: int | None = None - scheduled_at: str | None = None - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> ScheduleContext: - schedule = payload.get("schedule") - if not isinstance(schedule, dict): - raise ValueError("schedule payload is required") - return cls( - schedule_id=str(schedule.get("schedule_id", "")), - plugin_id=str(schedule.get("plugin_id", "")), - handler_id=str(schedule.get("handler_id", "")), - trigger_kind=str(schedule.get("trigger_kind", "")), - cron=( - str(schedule["cron"]) if isinstance(schedule.get("cron"), str) else None - ), - interval_seconds=( - int(schedule["interval_seconds"]) - if isinstance(schedule.get("interval_seconds"), int) - else None - ), - scheduled_at=( - str(schedule["scheduled_at"]) - if isinstance(schedule.get("scheduled_at"), str) - else None - ), - ) - - -__all__ = ["ScheduleContext"] diff --git a/astrbot-sdk/src/astrbot_sdk/session_waiter.py b/astrbot-sdk/src/astrbot_sdk/session_waiter.py deleted file mode 100644 index 2ecc6e0cc..000000000 --- a/astrbot-sdk/src/astrbot_sdk/session_waiter.py +++ /dev/null @@ -1,664 +0,0 @@ -"""Session-based conversational flow management. - -本模块实现会话等待器 (session_waiter),用于构建多轮对话流程。 - -核心组件: -- SessionController: 控制会话生命周期,支持超时管理、会话保持、历史记录 -- SessionWaiterManager: 管理活跃的会话等待器,处理事件分发和注册/注销 -- @session_waiter 装饰器: 将普通 handler 转换为会话式 handler - -使用场景: -当需要在用户首次触发后继续监听后续消息(如分步表单、问答游戏), -可使用 @session_waiter 装饰器自动管理会话状态和超时。 - -注意事项: -在当前桥接设计中,不应在普通 SDK handler 内直接 await session_waiter, -这会导致首次 dispatch 保持打开直到下一条消息到达。 -推荐写法是 `await ctx.register_task(waiter(...), "...")`,让 waiter 在后台任务中 -承接后续消息;直接 await 仅适用于你明确需要保持当前 dispatch 挂起的场景。 -""" - -from __future__ import annotations - -import asyncio -import time -import weakref -from collections.abc import Awaitable, Callable, Coroutine -from contextvars import ContextVar -from dataclasses import dataclass, field -from functools import wraps -from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast, overload - -from loguru import logger - -from ._internal.invocation_context import current_caller_plugin_id -from .events import MessageEvent - -_OwnerT = TypeVar("_OwnerT") -_P = ParamSpec("_P") -_ResultT = TypeVar("_ResultT") -_WaiterKey = tuple[str, str] - -_HANDLER_TASKS: weakref.WeakSet[asyncio.Task[Any]] = weakref.WeakSet() -_REGISTERED_BACKGROUND_TASKS: weakref.WeakSet[asyncio.Task[Any]] = weakref.WeakSet() -_WARNED_DIRECT_WAIT_TASKS: weakref.WeakSet[asyncio.Task[Any]] = weakref.WeakSet() -_ACTIVE_WAITER_KEY: ContextVar[_WaiterKey | None] = ContextVar( - "astrbot_sdk_active_waiter_key", - default=None, -) - - -class _TaskReentrantLock: - def __init__(self) -> None: - self._lock = asyncio.Lock() - self._owner: asyncio.Task[Any] | None = None - self._depth = 0 - - async def acquire(self) -> None: - current_task = asyncio.current_task() - if current_task is None: - raise RuntimeError("session waiter lock requires an active asyncio task") - if self._owner is current_task: - self._depth += 1 - return - await self._lock.acquire() - self._owner = current_task - self._depth = 1 - - def release(self) -> None: - current_task = asyncio.current_task() - if current_task is None or self._owner is not current_task: - raise RuntimeError("session waiter lock released by a non-owner task") - self._depth -= 1 - if self._depth > 0: - return - self._owner = None - self._lock.release() - - async def __aenter__(self) -> _TaskReentrantLock: - await self.acquire() - return self - - async def __aexit__(self, *_exc_info: object) -> None: - self.release() - - -def _mark_session_waiter_handler_task(task: asyncio.Task[Any]) -> None: - _HANDLER_TASKS.add(task) - - -def _unmark_session_waiter_handler_task(task: asyncio.Task[Any]) -> None: - _HANDLER_TASKS.discard(task) - - -def _mark_session_waiter_background_task(task: asyncio.Task[Any]) -> None: - _REGISTERED_BACKGROUND_TASKS.add(task) - - -def _unmark_session_waiter_background_task(task: asyncio.Task[Any]) -> None: - _REGISTERED_BACKGROUND_TASKS.discard(task) - - -class _SessionWaiterDecorator(Protocol): - @overload - def __call__( - self, - func: Callable[ - Concatenate[SessionController, MessageEvent, _P], - Awaitable[_ResultT], - ], - /, - ) -> Callable[Concatenate[MessageEvent, _P], Coroutine[Any, Any, _ResultT]]: ... - - @overload - def __call__( - self, - func: Callable[ - Concatenate[_OwnerT, SessionController, MessageEvent, _P], - Awaitable[_ResultT], - ], - /, - ) -> Callable[ - Concatenate[_OwnerT, MessageEvent, _P], - Coroutine[Any, Any, _ResultT], - ]: ... - - -@dataclass(slots=True) -class SessionController: - future: asyncio.Future[Any] = field(default_factory=asyncio.Future) - current_event: asyncio.Event | None = None - ts: float | None = None - timeout: float | None = None - history_chains: list[list[dict[str, Any]]] = field(default_factory=list) - - def stop(self, error: Exception | None = None) -> None: - if self.future.done(): - return - if error is not None: - self.future.set_exception(error) - else: - self.future.set_result(None) - - def keep(self, timeout: float = 0, reset_timeout: bool = False) -> None: - new_ts = time.time() - if reset_timeout: - if timeout <= 0: - self.stop() - return - else: - assert self.timeout is not None - assert self.ts is not None - left_timeout = self.timeout - (new_ts - self.ts) - timeout = left_timeout + timeout - if timeout <= 0: - self.stop() - return - - if self.current_event and not self.current_event.is_set(): - self.current_event.set() - - current_event = asyncio.Event() - self.current_event = current_event - self.ts = new_ts - self.timeout = timeout - asyncio.create_task(self._holding(current_event, timeout)) - - async def _holding(self, event: asyncio.Event, timeout: float) -> None: - try: - await asyncio.wait_for(event.wait(), timeout) - except asyncio.TimeoutError as exc: - self.stop(exc) - except asyncio.CancelledError: - return - - def get_history_chains(self) -> list[list[dict[str, Any]]]: - return list(self.history_chains) - - -@dataclass(slots=True) -class _WaiterEntry: - session_key: str - plugin_id: str - handler: Callable[[SessionController, MessageEvent], Awaitable[Any]] - controller: SessionController - record_history_chains: bool - unregister_enabled: bool = True - - -class SessionWaiterManager: - def __init__(self, *, plugin_id: str, peer) -> None: - self._plugin_id = plugin_id - self._peer = peer - self._entries: dict[str, dict[str, _WaiterEntry]] = {} - self._locks: dict[_WaiterKey, _TaskReentrantLock] = {} - - @staticmethod - def _make_key(*, plugin_id: str, session_key: str) -> _WaiterKey: - return (plugin_id, session_key) - - async def register( - self, - *, - event: MessageEvent, - handler: Callable[[SessionController, MessageEvent], Awaitable[Any]], - timeout: int, - record_history_chains: bool, - ) -> Any: - if event._context is None: - raise RuntimeError("session_waiter requires runtime context") - self._warn_if_direct_wait_in_handler(event) - session_key = event.unified_msg_origin - plugin_id = self._resolve_plugin_id(event) - entry = _WaiterEntry( - session_key=session_key, - plugin_id=plugin_id, - handler=handler, - controller=SessionController(), - record_history_chains=record_history_chains, - ) - previous = self._entries.setdefault(session_key, {}).get(plugin_id) - restorable_previous: _WaiterEntry | None = None - self._entries[session_key][plugin_id] = entry - self._lock_for(session_key, plugin_id) - if previous is not None: - previous.unregister_enabled = False - if _ACTIVE_WAITER_KEY.get() == self._make_key( - plugin_id=plugin_id, - session_key=session_key, - ): - restorable_previous = previous - else: - self._finish_entry( - previous, - RuntimeError("session waiter replaced by a newer waiter"), - ) - logger.warning( - "Session waiter replaced: plugin_id={} session_key={}", - plugin_id, - session_key, - ) - try: - await self._invoke_system_waiter( - "system.session_waiter.register", - session_key=session_key, - plugin_id=plugin_id, - ) - entry.controller.keep(timeout, reset_timeout=True) - except Exception: - entry.unregister_enabled = False - await self._remove_entry(entry) - if restorable_previous is not None: - self._entries.setdefault(session_key, {})[plugin_id] = ( - restorable_previous - ) - restorable_previous.unregister_enabled = True - self._lock_for(session_key, plugin_id) - raise - try: - return await entry.controller.future - finally: - if entry.unregister_enabled: - await self.unregister(session_key, plugin_id=plugin_id) - - def _warn_if_direct_wait_in_handler(self, event: MessageEvent) -> None: - current_task = asyncio.current_task() - if current_task is None: - return - if current_task not in _HANDLER_TASKS: - return - if current_task in _REGISTERED_BACKGROUND_TASKS: - return - if current_task in _WARNED_DIRECT_WAIT_TASKS: - return - _WARNED_DIRECT_WAIT_TASKS.add(current_task) - logger.warning( - "Direct await on session_waiter blocks the current handler dispatch; " - 'prefer `await ctx.register_task(waiter(...), "...")`: ' - "plugin_id={} session_key={}", - event._context.plugin_id, - event.unified_msg_origin, - ) - - async def wait_for_event( - self, - *, - event: MessageEvent, - timeout: int, - record_history_chains: bool = False, - ) -> MessageEvent: - future: asyncio.Future[MessageEvent] = ( - asyncio.get_running_loop().create_future() - ) - - async def _handler( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - if not future.done(): - future.set_result(waiter_event) - controller.stop() - - await self.register( - event=event, - handler=_handler, - timeout=timeout, - record_history_chains=record_history_chains, - ) - return future.result() - - async def unregister( - self, - session_key: str, - *, - plugin_id: str | None = None, - ) -> None: - target_plugin_id = self._resolve_unregister_plugin_id( - session_key, - plugin_id=plugin_id, - ) - if target_plugin_id is None: - return - lock_key = (session_key, target_plugin_id) - lock = self._lock_for(session_key, target_plugin_id) - removed = False - async with lock: - session_entries = self._entries.get(session_key) - if session_entries is None: - return - removed = session_entries.pop(target_plugin_id, None) is not None - if not session_entries: - self._entries.pop(session_key, None) - if self._locks.get(lock_key) is lock: - self._locks.pop(lock_key, None) - if not removed: - return - try: - await self._invoke_system_waiter( - "system.session_waiter.unregister", - session_key=session_key, - plugin_id=target_plugin_id, - ) - except Exception: - logger.debug( - "Failed to unregister session waiter: plugin_id={} session_key={}", - target_plugin_id, - session_key, - ) - - async def fail( - self, - session_key: str, - error: Exception, - *, - plugin_id: str | None = None, - ) -> bool: - resolved_plugin_id = plugin_id - if resolved_plugin_id is None: - caller_plugin_id = current_caller_plugin_id() - if caller_plugin_id: - resolved_plugin_id = caller_plugin_id - entry = self._select_entry( - session_key, - plugin_id=resolved_plugin_id, - allow_ambiguous=False, - missing_result=None, - ) - if entry is None: - return False - lock = self._lock_for(session_key, entry.plugin_id) - async with lock: - current = self._get_entry(session_key, entry.plugin_id) - if current is None or current.controller.future.done(): - return False - self._finish_entry(current, error) - return True - - def has_active_waiter(self, event: MessageEvent) -> bool: - session_key = event.unified_msg_origin - event_plugin_id = self._event_plugin_id(event) - if event_plugin_id is not None: - entry = self._get_entry(session_key, event_plugin_id) - return entry is not None and not entry.controller.future.done() - return bool(self.get_waiter_plugin_ids(session_key)) - - def has_waiter(self, event: MessageEvent) -> bool: - return self.has_active_waiter(event) - - def get_waiter_plugin_ids(self, session_key: str) -> list[str]: - return sorted( - plugin_id - for plugin_id, entry in self._entries.get(session_key, {}).items() - if not entry.controller.future.done() - ) - - async def dispatch( - self, - event: MessageEvent, - *, - plugin_id: str | None = None, - ) -> dict[str, Any]: - if event._context is None: - raise RuntimeError("session_waiter dispatch requires runtime context") - session_key = event.unified_msg_origin - entry = self._select_entry( - session_key, - plugin_id=plugin_id, - allow_ambiguous=False, - missing_result=None, - ambiguous_error=LookupError( - f"session waiter dispatch for session '{session_key}' requires explicit plugin identity" - ), - ) - if entry is None: - return {"sent_message": False, "stop": False, "call_llm": False} - lock = self._lock_for(session_key, entry.plugin_id) - async with lock: - current = self._get_entry(session_key, entry.plugin_id) - if current is None or current.controller.future.done(): - return {"sent_message": False, "stop": False, "call_llm": False} - waiter_event = self._build_waiter_event(current, event) - if current.record_history_chains: - chain = [] - raw_chain = ( - waiter_event.raw.get("chain") - if isinstance(waiter_event.raw, dict) - else None - ) - if isinstance(raw_chain, list): - chain = [dict(item) for item in raw_chain if isinstance(item, dict)] - current.controller.history_chains.append(chain) - active_key_token = _ACTIVE_WAITER_KEY.set( - self._make_key( - plugin_id=current.plugin_id, - session_key=current.session_key, - ) - ) - try: - # Keep follow-up handler execution serialized per waiter while still - # allowing nested waiter cleanup in the same task to re-enter safely. - await current.handler(current.controller, waiter_event) - finally: - _ACTIVE_WAITER_KEY.reset(active_key_token) - return { - "sent_message": False, - "stop": waiter_event.is_stopped(), - "call_llm": False, - } - - def _resolve_plugin_id(self, event: MessageEvent) -> str: - caller_plugin_id = current_caller_plugin_id() - if caller_plugin_id: - return caller_plugin_id - context = event._context - if context is not None and context.plugin_id.strip(): - return context.plugin_id - return self._plugin_id - - @staticmethod - def _event_plugin_id(event: MessageEvent) -> str | None: - context = event._context - if context is None: - return None - plugin_id = context.plugin_id.strip() - return plugin_id or None - - def _resolve_unregister_plugin_id( - self, - session_key: str, - *, - plugin_id: str | None, - ) -> str | None: - if plugin_id is not None: - normalized = str(plugin_id).strip() - return normalized or None - session_entries = self._entries.get(session_key, {}) - if len(session_entries) != 1: - return None - return next(iter(session_entries)) - - def _select_entry( - self, - session_key: str, - *, - plugin_id: str | None, - allow_ambiguous: bool, - missing_result: _WaiterEntry | None, - ambiguous_error: Exception | None = None, - ) -> _WaiterEntry | None: - if plugin_id is not None: - return self._get_entry(session_key, plugin_id) - active_entries = [ - entry - for entry in self._entries.get(session_key, {}).values() - if not entry.controller.future.done() - ] - if not active_entries: - return missing_result - if len(active_entries) > 1 and not allow_ambiguous: - if ambiguous_error is not None: - raise ambiguous_error - return missing_result - return active_entries[0] - - def _get_entry(self, session_key: str, plugin_id: str) -> _WaiterEntry | None: - return self._entries.get(session_key, {}).get(plugin_id) - - def _lock_for(self, session_key: str, plugin_id: str) -> _TaskReentrantLock: - return self._locks.setdefault((session_key, plugin_id), _TaskReentrantLock()) - - async def _remove_entry(self, entry: _WaiterEntry) -> None: - lock_key = (entry.session_key, entry.plugin_id) - lock = self._lock_for(entry.session_key, entry.plugin_id) - async with lock: - session_entries = self._entries.get(entry.session_key) - if session_entries is None: - return - current = session_entries.get(entry.plugin_id) - if current is not entry: - return - session_entries.pop(entry.plugin_id, None) - if not session_entries: - self._entries.pop(entry.session_key, None) - if self._locks.get(lock_key) is lock: - self._locks.pop(lock_key, None) - - @staticmethod - def _finish_entry(entry: _WaiterEntry, error: Exception | None = None) -> None: - entry.controller.stop(error) - if ( - entry.controller.current_event is not None - and not entry.controller.current_event.is_set() - ): - entry.controller.current_event.set() - - async def _invoke_system_waiter( - self, - capability: str, - *, - session_key: str, - plugin_id: str, - ) -> None: - from ._internal.invocation_context import caller_plugin_scope - - with caller_plugin_scope(plugin_id): - await self._peer.invoke( - capability, - {"session_key": session_key}, - ) - - def _build_waiter_event( - self, - entry: _WaiterEntry, - event: MessageEvent, - ) -> MessageEvent: - from .context import Context - - source_payload = self._source_payload_from_event(event) - cancel_token = ( - event._context.cancel_token if event._context is not None else None - ) - waiter_context = Context( - peer=self._peer, - plugin_id=entry.plugin_id, - request_id=( - event._context.request_id if event._context is not None else None - ), - cancel_token=cancel_token, - source_event_payload=source_payload, - ) - # Rebuild the event so the waiter always sees the registering plugin identity - # and the exact source payload that triggered the follow-up dispatch. - return MessageEvent.from_payload( - source_payload, - context=waiter_context, - ) - - @staticmethod - def _source_payload_from_event(event: MessageEvent) -> dict[str, Any]: - raw_payload = event.raw if isinstance(event.raw, dict) else None - if raw_payload is not None and { - "text", - "session_id", - "platform", - }.issubset(raw_payload): - return dict(raw_payload) - return event.to_payload() - - -def session_waiter( - timeout: int = 30, - *, - record_history_chains: bool = False, -) -> _SessionWaiterDecorator: - def decorator( - func: Callable[..., Awaitable[Any]], - ) -> Callable[..., Coroutine[Any, Any, Any]]: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - owner = None - event: MessageEvent | None = None - trailing_args: tuple[Any, ...] = () - if args and isinstance(args[0], MessageEvent): - event = args[0] - trailing_args = args[1:] - elif len(args) >= 2 and isinstance(args[1], MessageEvent): - owner = args[0] - event = args[1] - trailing_args = args[2:] - if event is None: - raise RuntimeError("session_waiter requires a MessageEvent argument") - if event._context is None: - raise RuntimeError("session_waiter requires runtime context") - manager = getattr(event._context.peer, "_session_waiter_manager", None) - if manager is None: - raise RuntimeError("session_waiter manager is unavailable") - - if owner is None: - free_func = cast(Callable[..., Awaitable[Any]], func) - - async def bound_handler( - controller: SessionController, - waiter_event: MessageEvent, - ) -> Any: - return await free_func( - controller, - waiter_event, - *trailing_args, - **kwargs, - ) - else: - method_func = cast(Callable[..., Awaitable[Any]], func) - - async def bound_handler( - controller: SessionController, - waiter_event: MessageEvent, - ) -> Any: - return await method_func( - owner, - controller, - waiter_event, - *trailing_args, - **kwargs, - ) - - return await manager.register( - event=event, - handler=bound_handler, - timeout=timeout, - record_history_chains=record_history_chains, - ) - - return wrapper - - return cast(_SessionWaiterDecorator, decorator) - - -__all__ = [ - "_OwnerT", - "_P", - "_ResultT", - "SessionController", - "SessionWaiterManager", - "session_waiter", -] diff --git a/astrbot-sdk/src/astrbot_sdk/star.py b/astrbot-sdk/src/astrbot_sdk/star.py deleted file mode 100644 index ef774b4e7..000000000 --- a/astrbot-sdk/src/astrbot_sdk/star.py +++ /dev/null @@ -1,132 +0,0 @@ -"""v4 原生插件基类。""" - -from __future__ import annotations - -import json -import traceback -from contextvars import ContextVar, Token -from typing import TYPE_CHECKING, Any, cast - -from loguru import logger - -from .errors import AstrBotError -from .plugin_kv import PluginKVStoreMixin - -if TYPE_CHECKING: - from .context import Context - - -class Star(PluginKVStoreMixin): - """v4 原生插件基类。""" - - __handlers__: tuple[str, ...] = () - - def __init_subclass__(cls, **kwargs: Any) -> None: - super().__init_subclass__(**kwargs) - from .decorators import get_handler_meta - - handlers: dict[str, None] = {} - for base in reversed(cls.__mro__): - for name, attr in getattr(base, "__dict__", {}).items(): - func = getattr(attr, "__func__", attr) - meta = get_handler_meta(func) - if meta is not None and meta.trigger is not None: - handlers[name] = None - cls.__handlers__ = tuple(handlers.keys()) - - @property - def context(self) -> Context | None: - return self._context_var().get() - - def _require_runtime_context(self) -> Context: - ctx = self.context - if ctx is None: - raise RuntimeError( - "Star runtime context is only available during lifecycle, " - "handler, and registered LLM tool execution" - ) - return ctx - - def _context_var(self) -> ContextVar[Context | None]: - existing_context_var = getattr(self, "__astrbot_context_var__", None) - if isinstance(existing_context_var, ContextVar): - return cast("ContextVar[Context | None]", existing_context_var) - created_context_var: ContextVar[Context | None] = ContextVar( - f"astrbot_sdk_star_context_{id(self)}", - default=None, - ) - setattr(self, "__astrbot_context_var__", created_context_var) - return created_context_var - - def _bind_runtime_context(self, ctx: Context | None) -> Token[Context | None]: - return self._context_var().set(ctx) - - def _reset_runtime_context(self, token: Token[Context | None]) -> None: - self._context_var().reset(token) - - async def on_start(self, ctx: Any | None = None) -> None: - await self.initialize() - - async def on_stop(self, ctx: Any | None = None) -> None: - await self.terminate() - - async def initialize(self) -> None: - return None - - async def terminate(self) -> None: - return None - - async def text_to_image( - self, - text: str, - *, - return_url: bool = True, - ) -> str: - return await self._require_runtime_context().text_to_image( - text, - return_url=return_url, - ) - - async def html_render( - self, - tmpl: str, - data: dict[str, Any], - *, - return_url: bool = True, - options: dict[str, Any] | None = None, - ) -> str: - return await self._require_runtime_context().html_render( - tmpl, - data, - return_url=return_url, - options=options, - ) - - @staticmethod - async def default_on_error(error: Exception, event, ctx) -> None: - del ctx - if isinstance(error, AstrBotError): - lines: list[str] = [] - if error.retryable: - lines.append("请求失败,请稍后重试") - elif error.hint: - lines.append(error.hint) - else: - lines.append(error.message) - if error.docs_url: - lines.append(f"文档:{error.docs_url}") - if error.details: - lines.append( - f"详情:{json.dumps(error.details, ensure_ascii=False, sort_keys=True)}" - ) - await event.reply("\n".join(lines)) - else: - await event.reply("出了点问题,请联系插件作者") - logger.error("handler 执行失败\n{}", traceback.format_exc()) - - async def on_error(self, error: Exception, event, ctx) -> None: - await Star.default_on_error(error, event, ctx) - - @classmethod - def __astrbot_is_new_star__(cls) -> bool: - return True diff --git a/astrbot-sdk/src/astrbot_sdk/star_tools.py b/astrbot-sdk/src/astrbot_sdk/star_tools.py deleted file mode 100644 index 62657ae31..000000000 --- a/astrbot-sdk/src/astrbot_sdk/star_tools.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Sequence -from typing import Any - -from ._internal.star_runtime import current_star_context -from .context import Context -from .message.components import BaseMessageComponent -from .message.result import MessageChain -from .message.session import MessageSession - - -class _StarToolsContextDescriptor: - def __get__(self, _instance: object, _owner: type[object]) -> Context | None: - return current_star_context() - - -class StarTools: - """Star 工具类,提供类方法访问运行时上下文能力。 - - 所有方法都通过当前上下文动态路由到对应的能力接口。 - 只在 lifecycle、handler 和已注册的 LLM tool 执行期间可用。 - """ - - _context = _StarToolsContextDescriptor() - - @classmethod - def _get_context(cls) -> Context | None: - """获取当前 Star 运行时上下文。""" - return cls._context - - @classmethod - def _require_context(cls) -> Context: - """获取当前运行时上下文,如果不存在则抛出 RuntimeError。""" - ctx = current_star_context() - if ctx is None: - raise RuntimeError( - "StarTools context is only available during lifecycle, " - "handler, and registered LLM tool execution" - ) - return ctx - - @classmethod - def get_llm_tool_manager(cls): - return cls._require_context().get_llm_tool_manager() - - @classmethod - async def activate_llm_tool(cls, name: str) -> bool: - return await cls._require_context().activate_llm_tool(name) - - @classmethod - async def deactivate_llm_tool(cls, name: str) -> bool: - return await cls._require_context().deactivate_llm_tool(name) - - @classmethod - async def send_message( - cls, - session: str | MessageSession, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - ) -> dict[str, Any]: - return await cls._require_context().send_message(session, content) - - @classmethod - async def send_message_by_id( - cls, - type: str, - id: str, - content: ( - str - | MessageChain - | Sequence[BaseMessageComponent] - | Sequence[dict[str, Any]] - ), - *, - platform: str, - ) -> dict[str, Any]: - return await cls._require_context().send_message_by_id( - type, - id, - content, - platform=platform, - ) - - @classmethod - async def register_llm_tool( - cls, - name: str, - parameters_schema: dict[str, Any], - desc: str, - func_obj: Callable[..., Awaitable[Any]] | Callable[..., Any], - *, - active: bool = True, - ) -> list[str]: - return await cls._require_context().register_llm_tool( - name, - parameters_schema, - desc, - func_obj, - active=active, - ) - - @classmethod - async def unregister_llm_tool(cls, name: str) -> bool: - return await cls._require_context().unregister_llm_tool(name) - - @classmethod - async def register_skill( - cls, - *, - name: str, - path: str, - description: str = "", - ): - return await cls._require_context().skills.register( - name=name, - path=path, - description=description, - ) - - @classmethod - async def unregister_skill(cls, name: str) -> bool: - return await cls._require_context().skills.unregister(name) diff --git a/astrbot-sdk/src/astrbot_sdk/testing.py b/astrbot-sdk/src/astrbot_sdk/testing.py deleted file mode 100644 index c6e185634..000000000 --- a/astrbot-sdk/src/astrbot_sdk/testing.py +++ /dev/null @@ -1,774 +0,0 @@ -"""本地开发与插件测试辅助。 - -`astrbot_sdk.testing` 是面向插件作者的稳定开发入口: - -- `PluginHarness` 负责复用现有 loader / dispatcher 执行链 -- `MockCapabilityRouter` 提供进程内 mock core 能力 -- `MockPeer` 让 `Context` 客户端继续走真实的 capability 调用路径 -- `StdoutPlatformSink` / `RecordedSend` 提供可观测的发送记录 - -这个模块刻意不暴露 runtime 内部编排数据结构,只封装本地开发/测试真正 -需要的最小稳定面。 -""" - -from __future__ import annotations - -import asyncio -import inspect -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from ._internal.star_runtime import bind_star_runtime -from ._internal.testing_support import ( - InMemoryDB, - InMemoryMemory, - MockCapabilityRouter, - MockContext, - MockLLMClient, - MockMessageEvent, - MockPeer, - MockPlatformClient, - RecordedSend, - StdoutPlatformSink, -) -from .context import CancelToken -from .context import Context as RuntimeContext -from .errors import AstrBotError -from .events import MessageEvent -from .protocol.descriptors import ( - CommandTrigger, - CompositeFilterSpec, - EventTrigger, - LocalFilterRefSpec, - MessageTrigger, - MessageTypeFilterSpec, - PlatformFilterSpec, - ScheduleTrigger, -) -from .protocol.messages import InvokeMessage -from .runtime._command_matching import ( - build_command_args, - build_regex_args, - match_command_name, -) -from .runtime._streaming import StreamExecution -from .runtime.handler_dispatcher import CapabilityDispatcher, HandlerDispatcher -from .runtime.loader import ( - LoadedHandler, - LoadedPlugin, - PluginSpec, - load_plugin, - load_plugin_config, - load_plugin_spec, - validate_plugin_spec, -) -from .star import Star - - -class _PluginLoadError(RuntimeError): - """本地 harness 初始化阶段的已知插件加载失败。""" - - -class _PluginExecutionError(RuntimeError): - """本地 harness 执行插件代码时的已知插件异常。""" - - -def _plugin_metadata_from_spec( - plugin: PluginSpec, - *, - enabled: bool, -) -> dict[str, Any]: - manifest = plugin.manifest_data - support_platforms = manifest.get("support_platforms") - return { - "name": plugin.name, - "display_name": str(manifest.get("display_name") or plugin.name), - "description": str(manifest.get("desc") or manifest.get("description") or ""), - "author": str(manifest.get("author") or ""), - "version": str(manifest.get("version") or "0.0.0"), - "enabled": enabled, - "reserved": bool(manifest.get("reserved", False)), - "support_platforms": [ - str(item) for item in support_platforms if isinstance(item, str) - ] - if isinstance(support_platforms, list) - else [], - "astrbot_version": ( - str(manifest.get("astrbot_version")) - if manifest.get("astrbot_version") is not None - else None - ), - } - - -def _handler_metadata_from_loaded( - plugin_id: str, loaded: LoadedHandler -) -> dict[str, Any]: - event_types: list[str] = [] - trigger = loaded.descriptor.trigger - if isinstance(trigger, EventTrigger): - event_types.append(trigger.type) - return { - "plugin_name": plugin_id, - "handler_full_name": loaded.descriptor.id, - "trigger_type": trigger.type - if isinstance(trigger, EventTrigger) - else str(getattr(trigger, "kind", trigger.type)), - "event_types": event_types, - "enabled": True, - "group_path": list( - loaded.descriptor.command_route.group_path - if loaded.descriptor.command_route is not None - else [] - ), - } - - -@dataclass(slots=True) -class LocalRuntimeConfig: - """本地 harness 的稳定配置对象。""" - - plugin_dir: Path - session_id: str = "local-session" - user_id: str = "local-user" - platform: str = "test" - group_id: str | None = None - event_type: str = "message" - - -@dataclass(slots=True) -class MockClock: - now: float = 0.0 - - def time(self) -> float: - return self.now - - def advance(self, seconds: float) -> float: - self.now += float(seconds) - return self.now - - -@dataclass(slots=True) -class SDKTestEnvironment: - root: Path - - @property - def plugins_dir(self) -> Path: - path = self.root / "plugins" - path.mkdir(parents=True, exist_ok=True) - return path - - def plugin_dir(self, name: str) -> Path: - path = self.plugins_dir / name - path.mkdir(parents=True, exist_ok=True) - return path - - -class PluginHarness: - """本地插件消息泵。 - - 这里复用真实的 loader / dispatcher 执行链,只负责: - - 在同一个事件循环里装配单插件运行时 - - 维持本地 mock core 与发送记录 - - 把后续消息持续送入同一个 dispatcher - """ - - def __init__( - self, - config: LocalRuntimeConfig, - *, - platform_sink: StdoutPlatformSink | None = None, - ) -> None: - self.config = config - self.platform_sink = platform_sink or StdoutPlatformSink() - self.router = MockCapabilityRouter(platform_sink=self.platform_sink) - self.peer = MockPeer(self.router) - self.plugin: PluginSpec | None = None - self.loaded_plugin: LoadedPlugin | None = None - self.dispatcher: HandlerDispatcher | None = None - self.capability_dispatcher: CapabilityDispatcher | None = None - self.lifecycle_context: RuntimeContext | None = None - self._request_counter = 0 - self._started = False - - @classmethod - def from_plugin_dir( - cls, - plugin_dir: str | Path, - *, - session_id: str = "local-session", - user_id: str = "local-user", - platform: str = "test", - group_id: str | None = None, - event_type: str = "message", - platform_sink: StdoutPlatformSink | None = None, - ) -> PluginHarness: - return cls( - LocalRuntimeConfig( - plugin_dir=Path(plugin_dir), - session_id=session_id, - user_id=user_id, - platform=platform, - group_id=group_id, - event_type=event_type, - ), - platform_sink=platform_sink, - ) - - async def __aenter__(self) -> PluginHarness: - await self.start() - return self - - async def __aexit__(self, exc_type, exc, tb) -> None: - await self.stop() - - @property - def sent_messages(self) -> list[RecordedSend]: - return list(self.platform_sink.records) - - def clear_sent_messages(self) -> None: - self.platform_sink.clear() - - async def start(self) -> None: - if self._started: - return - try: - self.plugin = load_plugin_spec(self.config.plugin_dir) - validate_plugin_spec(self.plugin) - self.loaded_plugin = load_plugin(self.plugin) - except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 - raise _PluginLoadError(str(exc)) from exc - self.dispatcher = HandlerDispatcher( - plugin_id=self.plugin.name, - peer=self.peer, - handlers=self.loaded_plugin.handlers, - ) - self.capability_dispatcher = CapabilityDispatcher( - plugin_id=self.plugin.name, - peer=self.peer, - capabilities=self.loaded_plugin.capabilities, - llm_tools=self.loaded_plugin.llm_tools, - ) - self.lifecycle_context = RuntimeContext( - peer=self.peer, - plugin_id=self.plugin.name, - ) - self.router.upsert_plugin( - metadata=_plugin_metadata_from_spec(self.plugin, enabled=True), - config=load_plugin_config(self.plugin), - ) - self.router.set_plugin_handlers( - self.plugin.name, - [ - _handler_metadata_from_loaded(self.plugin.name, handler) - for handler in self.loaded_plugin.handlers - ], - ) - self.router.set_plugin_llm_tools( - self.plugin.name, - [tool.spec.to_payload() for tool in self.loaded_plugin.llm_tools], - ) - self.router.set_plugin_agents( - self.plugin.name, - [agent.spec.to_payload() for agent in self.loaded_plugin.agents], - ) - try: - await self._run_lifecycle("on_start") - except AstrBotError: - raise - except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 - raise _PluginExecutionError(str(exc)) from exc - self._started = True - - async def stop(self) -> None: - if ( - not self._started - or self.loaded_plugin is None - or self.lifecycle_context is None - ): - return - try: - await self._run_lifecycle("on_stop") - finally: - if self.plugin is not None: - self.router.set_plugin_enabled(self.plugin.name, False) - self.router.set_plugin_handlers(self.plugin.name, []) - self.router.remove_dynamic_command_routes_for_plugin(self.plugin.name) - self.router.remove_http_apis_for_plugin(self.plugin.name) - self._started = False - - async def dispatch_text( - self, - text: str, - *, - session_id: str | None = None, - user_id: str | None = None, - platform: str | None = None, - group_id: str | None = None, - event_type: str | None = None, - request_id: str | None = None, - ) -> list[RecordedSend]: - payload = self.build_event_payload( - text=text, - session_id=session_id, - user_id=user_id, - platform=platform, - group_id=group_id, - event_type=event_type, - request_id=request_id, - ) - return await self.dispatch_event(payload, request_id=request_id) - - async def dispatch_event( - self, - event_payload: dict[str, Any], - *, - request_id: str | None = None, - ) -> list[RecordedSend]: - await self.start() - assert self.loaded_plugin is not None - assert self.dispatcher is not None - - start_index = len(self.platform_sink.records) - if self._has_waiter_for_event(event_payload): - await self._invoke_session_waiter( - event_payload, - request_id=request_id, - ) - await self._wait_for_followup_side_effects( - start_index=start_index, - event_payload=event_payload, - ) - return self.platform_sink.records[start_index:] - - matches = self._match_handlers(event_payload) - if not matches: - raise AstrBotError.invalid_input("未找到匹配的 handler") - for loaded, args in matches: - await self._invoke_handler( - loaded, - event_payload, - args=args, - request_id=request_id, - ) - return self.platform_sink.records[start_index:] - - async def invoke_capability( - self, - capability: str, - payload: dict[str, Any], - *, - request_id: str | None = None, - stream: bool = False, - ) -> dict[str, Any] | StreamExecution: - await self.start() - assert self.capability_dispatcher is not None - message = InvokeMessage( - id=request_id or self._next_request_id("cap"), - capability=capability, - input=dict(payload), - stream=stream, - ) - try: - return await self.capability_dispatcher.invoke(message, CancelToken()) - except AstrBotError: - raise - except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 - raise _PluginExecutionError(str(exc)) from exc - - def build_event_payload( - self, - *, - text: str, - session_id: str | None = None, - user_id: str | None = None, - platform: str | None = None, - group_id: str | None = None, - event_type: str | None = None, - request_id: str | None = None, - ) -> dict[str, Any]: - session_value = session_id or self.config.session_id - group_value = group_id if group_id is not None else self.config.group_id - event_type_value = event_type or self.config.event_type - payload = { - "type": event_type_value, - "event_type": event_type_value, - "text": text, - "session_id": session_value, - "user_id": user_id or self.config.user_id, - "platform": platform or self.config.platform, - "platform_id": platform or self.config.platform, - "group_id": group_value, - "self_id": f"{platform or self.config.platform}-bot", - "sender_name": str(user_id or self.config.user_id or ""), - "is_admin": False, - "raw": { - "trace_id": request_id or self._next_request_id("trace"), - "event_type": event_type_value, - }, - } - if group_value: - payload["message_type"] = "group" - elif payload["user_id"]: - payload["message_type"] = "private" - else: - payload["message_type"] = "other" - return payload - - async def _invoke_handler( - self, - loaded: LoadedHandler, - event_payload: dict[str, Any], - *, - args: dict[str, Any], - request_id: str | None = None, - ) -> None: - assert self.dispatcher is not None - message = InvokeMessage( - id=request_id or self._next_request_id("msg"), - capability="handler.invoke", - input={ - "handler_id": loaded.descriptor.id, - "event": dict(event_payload), - "args": dict(args), - }, - ) - try: - await self.dispatcher.invoke(message, CancelToken()) - except AstrBotError: - raise - except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 - raise _PluginExecutionError(str(exc)) from exc - - async def _invoke_session_waiter( - self, - event_payload: dict[str, Any], - *, - request_id: str | None = None, - ) -> None: - assert self.dispatcher is not None - message = InvokeMessage( - id=request_id or self._next_request_id("msg"), - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": dict(event_payload), - "args": {}, - }, - ) - try: - await self.dispatcher.invoke(message, CancelToken()) - except AstrBotError: - raise - except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 - raise _PluginExecutionError(str(exc)) from exc - - async def _wait_for_followup_side_effects( - self, - *, - start_index: int, - event_payload: dict[str, Any], - ) -> None: - settled_rounds = 0 - for _ in range(20): - if len(self.platform_sink.records) > start_index: - return - await asyncio.sleep(0) - if self._has_waiter_for_event(event_payload): - settled_rounds = 0 - continue - settled_rounds += 1 - if settled_rounds >= 3: - return - - async def _run_lifecycle(self, method_name: str) -> None: - assert self.loaded_plugin is not None - assert self.lifecycle_context is not None - - for instance in self.loaded_plugin.instances: - hook = self._resolve_lifecycle_hook(instance, method_name) - if hook is None: - continue - args: list[Any] = [] - try: - signature = inspect.signature(hook) - except (TypeError, ValueError): - signature = None - if signature is not None: - positional_params = [ - parameter - for parameter in signature.parameters.values() - if parameter.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - ] - if positional_params: - args.append(self.lifecycle_context) - with bind_star_runtime( - instance if isinstance(instance, Star) else None, - self.lifecycle_context, - ): - result = hook(*args) - if inspect.isawaitable(result): - await result - - def _match_handlers( - self, - event_payload: dict[str, Any], - ) -> list[tuple[LoadedHandler, dict[str, Any]]]: - assert self.loaded_plugin is not None - ranked: list[tuple[int, int, LoadedHandler, dict[str, Any]]] = [] - for index, loaded in enumerate(self.loaded_plugin.handlers): - args = self._match_handler(loaded, event_payload) - if args is None: - continue - ranked.append((loaded.descriptor.priority, index, loaded, args)) - for dynamic in self._match_dynamic_handlers(event_payload): - ranked.append(dynamic) - ranked.sort(key=lambda item: (-item[0], item[1])) - return [(loaded, args) for _priority, _index, loaded, args in ranked] - - def _match_dynamic_handlers( - self, - event_payload: dict[str, Any], - ) -> list[tuple[int, int, LoadedHandler, dict[str, Any]]]: - assert self.loaded_plugin is not None - assert self.plugin is not None - ranked: list[tuple[int, int, LoadedHandler, dict[str, Any]]] = [] - routes = self.router.list_dynamic_command_routes(self.plugin.name) - handler_map = { - loaded.descriptor.id: loaded for loaded in self.loaded_plugin.handlers - } - base_order = len(self.loaded_plugin.handlers) - for index, route in enumerate(routes): - if not isinstance(route, dict): - continue - handler_full_name = str(route.get("handler_full_name", "")).strip() - loaded = handler_map.get(handler_full_name) - if loaded is None: - continue - args = self._match_dynamic_route(loaded, route, event_payload) - if args is None: - continue - priority = route.get("priority", loaded.descriptor.priority) - if not isinstance(priority, int) or isinstance(priority, bool): - priority = loaded.descriptor.priority - ranked.append((priority, base_order + index, loaded, args)) - return ranked - - def _match_dynamic_route( - self, - loaded: LoadedHandler, - route: dict[str, Any], - event_payload: dict[str, Any], - ) -> dict[str, Any] | None: - if not self._passes_filters(loaded, event_payload): - return None - command_name = str(route.get("command_name", "")).strip() - if not command_name: - return None - text = str(event_payload.get("text", "")) - if bool(route.get("use_regex", False)): - match = re.search(command_name, text) - if match is None: - return None - return build_regex_args(loaded.descriptor.param_specs, match) - remainder = match_command_name(text, command_name) - if remainder is None: - return None - return build_command_args(loaded.descriptor.param_specs, remainder) - - def _match_handler( - self, - loaded: LoadedHandler, - event_payload: dict[str, Any], - ) -> dict[str, Any] | None: - trigger = loaded.descriptor.trigger - if isinstance(trigger, CommandTrigger): - return self._match_command_trigger(loaded, trigger, event_payload) - if isinstance(trigger, MessageTrigger): - return self._match_message_trigger(loaded, trigger, event_payload) - if isinstance(trigger, EventTrigger): - current_type = str( - event_payload.get("event_type") - or event_payload.get("type") - or "message" - ) - if current_type != trigger.event_type: - return None - return {} - if isinstance(trigger, ScheduleTrigger): - if ( - str(event_payload.get("event_type") or event_payload.get("type")) - == "schedule" - ): - return {} - return None - return None - - def _match_command_trigger( - self, - loaded: LoadedHandler, - trigger: CommandTrigger, - event_payload: dict[str, Any], - ) -> dict[str, Any] | None: - if not self._passes_filters(loaded, event_payload): - return None - text = str(event_payload.get("text", "")).strip() - for command_name in [trigger.command, *trigger.aliases]: - if not command_name: - continue - match = match_command_name(text, command_name) - if match is None: - continue - return build_command_args(loaded.descriptor.param_specs, match) - return None - - def _match_message_trigger( - self, - loaded: LoadedHandler, - trigger: MessageTrigger, - event_payload: dict[str, Any], - ) -> dict[str, Any] | None: - if not self._passes_filters(loaded, event_payload): - return None - text = str(event_payload.get("text", "")) - if trigger.regex: - match = re.search(trigger.regex, text) - if match is None: - return None - return build_regex_args(loaded.descriptor.param_specs, match) - if trigger.keywords and not any( - keyword in text for keyword in trigger.keywords - ): - return None - return {} - - def _passes_filters( - self, - loaded: LoadedHandler, - event_payload: dict[str, Any], - ) -> bool: - for filter_spec in loaded.descriptor.filters: - if isinstance(filter_spec, PlatformFilterSpec): - if str(event_payload.get("platform", "")) not in filter_spec.platforms: - return False - elif isinstance(filter_spec, MessageTypeFilterSpec): - if ( - self._message_type_name(event_payload) - not in filter_spec.message_types - ): - return False - elif isinstance(filter_spec, CompositeFilterSpec): - if not self._passes_composite_filter(filter_spec, event_payload): - return False - elif isinstance(filter_spec, LocalFilterRefSpec): - continue - return True - - def _passes_composite_filter( - self, - filter_spec: CompositeFilterSpec, - event_payload: dict[str, Any], - ) -> bool: - results: list[bool] = [] - for child in filter_spec.children: - if isinstance(child, PlatformFilterSpec): - results.append( - str(event_payload.get("platform", "")) in child.platforms - ) - elif isinstance(child, MessageTypeFilterSpec): - results.append( - self._message_type_name(event_payload) in child.message_types - ) - elif isinstance(child, LocalFilterRefSpec): - results.append(True) - elif isinstance(child, CompositeFilterSpec): - results.append(self._passes_composite_filter(child, event_payload)) - if filter_spec.kind == "and": - return all(results) - return any(results) - - def _has_waiter_for_event(self, event_payload: dict[str, Any]) -> bool: - assert self.dispatcher is not None - probe_event = MessageEvent.from_payload( - event_payload, - context=self.lifecycle_context, - ) - public_probe = getattr(self.dispatcher, "has_active_waiter", None) - if callable(public_probe): - return bool(public_probe(probe_event)) - session_waiters = getattr(self.dispatcher, "_session_waiters", None) - if session_waiters is None: - return False - if hasattr(session_waiters, "has_waiter"): - return session_waiters.has_waiter(probe_event) - if isinstance(session_waiters, dict): - return any( - manager.has_waiter(probe_event) - for manager in session_waiters.values() - if hasattr(manager, "has_waiter") - ) - return False - - @staticmethod - def _message_type_name(event_payload: dict[str, Any]) -> str: - explicit = str(event_payload.get("message_type", "")).lower() - if explicit in {"group", "private", "other"}: - return explicit - if event_payload.get("group_id"): - return "group" - if event_payload.get("user_id"): - return "private" - return "other" - - @staticmethod - def _resolve_lifecycle_hook(instance: Any, method_name: str): - hook = getattr(instance, method_name, None) - marker = getattr(instance.__class__, "__astrbot_is_new_star__", None) - is_new_star = True - if callable(marker): - is_new_star = bool(marker()) - - if hook is not None and callable(hook): - bound_func = getattr(hook, "__func__", hook) - star_default = getattr(Star, method_name, None) - if star_default is None or bound_func is not star_default: - return hook - - if not is_new_star: - alias = {"on_start": "initialize", "on_stop": "terminate"}.get(method_name) - if alias is not None: - legacy_hook = getattr(instance, alias, None) - if legacy_hook is not None and callable(legacy_hook): - return legacy_hook - - if hook is not None and callable(hook): - return hook - return None - - def _next_request_id(self, prefix: str) -> str: - self._request_counter += 1 - return f"{prefix}_{self._request_counter:04d}" - - -__all__ = [ - "InMemoryDB", - "InMemoryMemory", - "LocalRuntimeConfig", - "MockClock", - "MockCapabilityRouter", - "MockContext", - "MockLLMClient", - "MockMessageEvent", - "MockPeer", - "MockPlatformClient", - "SDKTestEnvironment", - "PluginHarness", - "RecordedSend", - "StdoutPlatformSink", -] diff --git a/astrbot-sdk/src/astrbot_sdk/types.py b/astrbot-sdk/src/astrbot_sdk/types.py deleted file mode 100644 index c2bc911ec..000000000 --- a/astrbot-sdk/src/astrbot_sdk/types.py +++ /dev/null @@ -1,22 +0,0 @@ -"""SDK parameter helper types. - -本模块提供 SDK 参数类型助手,用于增强命令参数解析能力。 - -GreedyStr: -用于标记"贪婪字符串"参数,在命令解析时将剩余所有文本作为一个整体参数。 -例如:/echo hello world this is a test -如果最后一个参数类型为 GreedyStr,将获取 "hello world this is a test" 而非仅 "hello" - -使用方式: -在 handler 签名中将最后一个参数标注为 GreedyStr 类型, -_loader_support 会识别此类型并调整参数解析逻辑。 -""" - -from __future__ import annotations - - -class GreedyStr(str): - """Consume the remaining command text as one argument.""" - - -__all__ = ["GreedyStr"] diff --git a/astrbot-sdk/tests/conftest.py b/astrbot-sdk/tests/conftest.py deleted file mode 100644 index 574158965..000000000 --- a/astrbot-sdk/tests/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "src" - -while str(SRC) in sys.path: - sys.path.remove(str(SRC)) - -sys.path.insert(0, str(SRC)) diff --git a/astrbot-sdk/tests/test_cli_init.py b/astrbot-sdk/tests/test_cli_init.py deleted file mode 100644 index 2301d2ed7..000000000 --- a/astrbot-sdk/tests/test_cli_init.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from click.testing import CliRunner - -from astrbot_sdk.cli import cli - - -def test_init_normalizes_plugin_name_and_adds_prefix() -> None: - runner = CliRunner() - - with runner.isolated_filesystem(): - result = runner.invoke(cli, ["init", "demo-plugin"]) - - assert result.exit_code == 0 - plugin_dir = Path("astrbot_plugin_demo_plugin") - assert plugin_dir.exists() - manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") - assert "name: astrbot_plugin_demo_plugin" in manifest - assert "display_name: demo-plugin" in manifest - assert "version: 1.0.0" in manifest - - -def test_init_interactive_prompts_and_sanitizes_name() -> None: - runner = CliRunner() - - with runner.isolated_filesystem(): - result = runner.invoke( - cli, - ["init"], - input="\nMy Plugin,Name;Beta\nAlice\nExample plugin\n\n", - ) - - assert result.exit_code == 0 - assert "该字段不能为空,请重新输入。" in result.output - plugin_dir = Path("astrbot_plugin_my_plugin_name_beta") - assert plugin_dir.exists() - manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") - assert "name: astrbot_plugin_my_plugin_name_beta" in manifest - assert "display_name: My Plugin,Name;Beta" in manifest - assert "author: Alice" in manifest - assert "desc: Example plugin" in manifest - assert "version: 1.0.0" in manifest diff --git a/astrbot-sdk/tests/test_client_regressions.py b/astrbot-sdk/tests/test_client_regressions.py deleted file mode 100644 index 45372c3a6..000000000 --- a/astrbot-sdk/tests/test_client_regressions.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.clients.memory import MemoryClient -from astrbot_sdk.clients.metadata import MetadataClient - - -class _FakeProxy: - def __init__(self, responses: dict[str, dict[str, Any]] | None = None) -> None: - self.responses = responses or {} - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: - self.calls.append((name, dict(payload))) - return dict(self.responses.get(name, {})) - - -@pytest.mark.asyncio -async def test_memory_get_many_skips_non_dict_items() -> None: - proxy = _FakeProxy( - { - "memory.get_many": { - "items": [ - {"key": "pref1", "value": {"theme": "dark"}}, - ["unexpected"], - None, - {"key": "pref2", "value": None}, - ] - } - } - ) - client = MemoryClient(proxy) # type: ignore[arg-type] - - items = await client.get_many(["pref1", "pref2"]) - - assert items == [ - {"key": "pref1", "value": {"theme": "dark"}}, - {"key": "pref2", "value": None}, - ] - - -@pytest.mark.asyncio -async def test_capability_proxy_ignores_magicmock_placeholder_attributes() -> None: - peer = MagicMock() - peer.invoke = AsyncMock(return_value={}) - proxy = CapabilityProxy(peer) - - result = await proxy.call("metadata.get_plugin", {"name": "demo"}) - - assert result == {} - peer.invoke.assert_awaited_once_with( - "metadata.get_plugin", - {"name": "demo"}, - stream=False, - ) - - -@pytest.mark.asyncio -async def test_metadata_client_rejects_cross_plugin_config_access() -> None: - proxy = _FakeProxy( - { - "metadata.get_plugin_config": { - "config": {"api_key": "hidden"}, - } - } - ) - client = MetadataClient(proxy, plugin_id="current-plugin") - - with pytest.raises(PermissionError, match="只允许访问当前插件自己的配置"): - await client.get_plugin_config("other-plugin") - - assert proxy.calls == [] diff --git a/astrbot-sdk/tests/test_command_matching.py b/astrbot-sdk/tests/test_command_matching.py deleted file mode 100644 index 13dd1eb80..000000000 --- a/astrbot-sdk/tests/test_command_matching.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from astrbot_sdk.protocol.descriptors import ParamSpec -from astrbot_sdk.runtime._command_matching import ( - build_command_args, - build_regex_args, - match_command_name, - split_command_remainder, -) - - -def test_match_command_name_trims_input_consistently() -> None: - assert match_command_name(" ping ", "ping") == "" - assert match_command_name(" ping hello world ", "ping") == "hello world" - assert match_command_name("pingpong", "ping") is None - - -def test_build_command_args_supports_quotes_and_greedy_tail() -> None: - param_specs = [ - ParamSpec(name="name", type="str"), - ParamSpec(name="message", type="greedy_str"), - ] - - args = build_command_args(param_specs, '"alpha beta" "hello world" tail') - - assert args == {"name": "alpha beta", "message": "hello world tail"} - - -def test_split_command_remainder_falls_back_on_invalid_quotes() -> None: - assert split_command_remainder('"unterminated quote test') == [ - '"unterminated', - "quote", - "test", - ] - - -def test_build_regex_args_preserves_named_and_positional_mapping() -> None: - param_specs = [ - ParamSpec(name="first", type="str"), - ParamSpec(name="second", type="str"), - ParamSpec(name="third", type="str"), - ] - match = re.search(r"(?P\w+)-(\w+)-(\w+)", "named-positional-tail") - - assert match is not None - assert build_regex_args(param_specs, match) == { - "second": "named", - "first": "named", - "third": "positional", - } diff --git a/astrbot-sdk/tests/test_command_model_parsing.py b/astrbot-sdk/tests/test_command_model_parsing.py deleted file mode 100644 index 4e8f81dfb..000000000 --- a/astrbot-sdk/tests/test_command_model_parsing.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -针对 command_model 解析逻辑的边界场景单元测试。 -覆盖:--help 生成、位置参数超限、重复 flag、连字符映射。 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from pydantic import BaseModel - -from astrbot_sdk._internal.command_model import ( - CommandModelParseResult, - ResolvedCommandModelParam, - format_command_model_help, - parse_command_model_remainder, - resolve_command_model_param, -) -from astrbot_sdk.errors import AstrBotError - - -# ── 测试用模型 ──────────────────────────────────────────────────── - - -class SimpleModel(BaseModel): - name: str - count: int = 1 - verbose: bool = False - - -class HyphenModel(BaseModel): - output_dir: str - max_retries: int = 3 - - -# ── 辅助:构建 ResolvedCommandModelParam ────────────────────────── - - -def _param(model_cls: type[BaseModel]) -> ResolvedCommandModelParam: - return ResolvedCommandModelParam(name="args", model_cls=model_cls) - - -# ── --help 测试 ─────────────────────────────────────────────────── - - -def test_help_flag_short() -> None: - result = parse_command_model_remainder( - remainder="-h", model_param=_param(SimpleModel), command_name="test" - ) - assert result.model is None - assert result.help_text is not None - assert "test" in result.help_text - - -def test_help_flag_long() -> None: - result = parse_command_model_remainder( - remainder="--help", model_param=_param(SimpleModel), command_name="greet" - ) - assert result.model is None - assert result.help_text is not None - assert "name" in result.help_text - assert "count" in result.help_text - assert "verbose" in result.help_text - - -def test_format_command_model_help_contains_bool_hint() -> None: - text = format_command_model_help("myCmd", SimpleModel) - assert "--verbose" in text - assert "--no-verbose" in text - - -# ── 位置参数超限 ────────────────────────────────────────────────── - - -def test_too_many_positional_args_raises() -> None: - with pytest.raises(AstrBotError) as exc_info: - parse_command_model_remainder( - remainder="alice 10 extra", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert "Too many positional arguments" in str(exc_info.value) - - -def test_exactly_right_positional_args_succeeds() -> None: - result = parse_command_model_remainder( - remainder="alice 5", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert result.model is not None - assert result.model.name == "alice" # type: ignore[attr-defined] - assert result.model.count == 5 # type: ignore[attr-defined] - - -# ── 重复 flag ───────────────────────────────────────────────────── - - -def test_duplicate_named_flag_raises() -> None: - with pytest.raises(AstrBotError) as exc_info: - parse_command_model_remainder( - remainder="--name alice --name bob", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert "Duplicate field" in str(exc_info.value) - - -def test_duplicate_bool_flag_raises() -> None: - with pytest.raises(AstrBotError) as exc_info: - parse_command_model_remainder( - remainder="--verbose --verbose", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert "Duplicate field" in str(exc_info.value) - - -# ── 连字符映射下划线 ─────────────────────────────────────────────── - - -def test_hyphen_flag_maps_to_underscore_field() -> None: - result = parse_command_model_remainder( - remainder="--output-dir /tmp --max-retries 5", - model_param=_param(HyphenModel), - command_name="build", - ) - assert result.model is not None - assert result.model.output_dir == "/tmp" # type: ignore[attr-defined] - assert result.model.max_retries == 5 # type: ignore[attr-defined] - - -def test_underscore_flag_still_works() -> None: - """直接使用下划线形式也应正常解析(向后兼容)。""" - result = parse_command_model_remainder( - remainder="--output_dir /out", - model_param=_param(HyphenModel), - command_name="build", - ) - assert result.model is not None - assert result.model.output_dir == "/out" # type: ignore[attr-defined] - - -# ── bool 标志 --no- 前缀 ────────────────────────────────────────── - - -def test_bool_negation_flag() -> None: - result = parse_command_model_remainder( - remainder="alice --no-verbose", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert result.model is not None - assert result.model.verbose is False # type: ignore[attr-defined] - - -def test_bool_positive_flag() -> None: - result = parse_command_model_remainder( - remainder="alice --verbose", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert result.model is not None - assert result.model.verbose is True # type: ignore[attr-defined] - - -# ── 未知字段 ────────────────────────────────────────────────────── - - -def test_unknown_flag_raises() -> None: - with pytest.raises(AstrBotError) as exc_info: - parse_command_model_remainder( - remainder="--nonexistent foo", - model_param=_param(SimpleModel), - command_name="cmd", - ) - assert "Unknown field" in str(exc_info.value) - - -# ── resolve_command_model_param ─────────────────────────────────── - - -def test_resolve_finds_model_param() -> None: - def handler(event: object, args: SimpleModel) -> None: ... - - resolved = resolve_command_model_param(handler) - assert resolved is not None - assert resolved.model_cls is SimpleModel - - -def test_resolve_returns_none_for_plain_handler() -> None: - def handler(event: object, name: str) -> None: ... - - resolved = resolve_command_model_param(handler) - assert resolved is None diff --git a/astrbot-sdk/tests/test_context_llm_tool_registration.py b/astrbot-sdk/tests/test_context_llm_tool_registration.py deleted file mode 100644 index 7a697a851..000000000 --- a/astrbot-sdk/tests/test_context_llm_tool_registration.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest - -from astrbot_sdk._internal.testing_support import MockContext -from astrbot_sdk.llm.entities import LLMToolSpec - - -class RecordingDispatcher: - def __init__(self) -> None: - self.added: list[dict[str, Any]] = [] - self.removed: list[tuple[str, str]] = [] - - def add_dynamic_llm_tool( - self, - *, - plugin_id: str, - spec: LLMToolSpec, - callable_obj, - owner: Any | None = None, - ) -> None: - self.added.append( - { - "plugin_id": plugin_id, - "spec": spec, - "callable_obj": callable_obj, - "owner": owner, - } - ) - - def remove_llm_tool(self, plugin_id: str, name: str) -> bool: - self.removed.append((plugin_id, name)) - return True - - -@pytest.mark.asyncio -async def test_register_llm_tool_keeps_manager_and_dispatcher_specs_aligned() -> None: - ctx = MockContext() - dispatcher = RecordingDispatcher() - ctx.peer._sdk_capability_dispatcher = dispatcher - - async def echo_tool(text: str) -> str: - return text - - names = await ctx.register_llm_tool( - "echo", - {"type": "object", "properties": {"text": {"type": "string"}}}, - "Echo the provided text", - echo_tool, - active=False, - ) - - assert names == ["echo"] - registered = await ctx.get_llm_tool_manager().get("echo") - assert registered is not None - assert registered.name == "echo" - assert registered.description == "Echo the provided text" - assert registered.parameters_schema == { - "type": "object", - "properties": {"text": {"type": "string"}}, - } - assert registered.handler_ref == "__dynamic_llm_tool__:echo" - assert registered.active is False - - assert len(dispatcher.added) == 1 - added = dispatcher.added[0] - assert added["plugin_id"] == "test-plugin" - assert added["callable_obj"] is echo_tool - assert added["owner"] is None - assert added["spec"].model_dump() == registered.model_dump() - - removed = await ctx.unregister_llm_tool("echo") - assert removed is True - assert dispatcher.removed == [("test-plugin", "echo")] - assert await ctx.get_llm_tool_manager().get("echo") is None diff --git a/astrbot-sdk/tests/test_context_register_task.py b/astrbot-sdk/tests/test_context_register_task.py deleted file mode 100644 index 3bb53da0e..000000000 --- a/astrbot-sdk/tests/test_context_register_task.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest - -from astrbot_sdk._internal.testing_support import MockContext - - -class RecordingLogger: - def __init__(self) -> None: - self.debug_calls: list[tuple[str, str, str]] = [] - self.exception_calls: list[tuple[str, str, str]] = [] - - def debug(self, message: str, plugin_id: str, desc: str) -> None: - self.debug_calls.append((message, plugin_id, desc)) - - def exception(self, message: str, plugin_id: str, desc: str) -> None: - self.exception_calls.append((message, plugin_id, desc)) - - -@pytest.mark.asyncio -async def test_register_task_accepts_coroutine() -> None: - ctx = MockContext() - - async def background() -> str: - await asyncio.sleep(0) - return "done" - - task = await ctx.register_task(background(), "coroutine") - - assert isinstance(task, asyncio.Task) - assert await task == "done" - - -@pytest.mark.asyncio -async def test_register_task_wraps_future_inputs() -> None: - ctx = MockContext() - loop = asyncio.get_running_loop() - future: asyncio.Future[str] = loop.create_future() - - task = await ctx.register_task(future, "future") - future.set_result("done") - - assert isinstance(task, asyncio.Task) - assert task is not future - assert await task == "done" - - -@pytest.mark.asyncio -async def test_register_task_logs_cancel_once() -> None: - logger = RecordingLogger() - ctx = MockContext(logger=logger) - started = asyncio.Event() - - async def background() -> None: - started.set() - await asyncio.Future() - - task = await ctx.register_task(background(), "cancelled") - await started.wait() - task.cancel() - - with pytest.raises(asyncio.CancelledError): - await task - - assert logger.debug_calls == [ - ( - "SDK background task cancelled: plugin_id={} desc={}", - "test-plugin", - "cancelled", - ) - ] - assert logger.exception_calls == [] - - -@pytest.mark.asyncio -async def test_register_task_logs_failures() -> None: - logger = RecordingLogger() - ctx = MockContext(logger=logger) - - async def background() -> None: - raise RuntimeError("boom") - - task = await ctx.register_task(background(), "failing") - - with pytest.raises(RuntimeError, match="boom"): - await task - - assert logger.debug_calls == [] - assert logger.exception_calls == [ - ( - "SDK background task failed: plugin_id={} desc={}", - "test-plugin", - "failing", - ) - ] diff --git a/astrbot-sdk/tests/test_db_runtime.py b/astrbot-sdk/tests/test_db_runtime.py deleted file mode 100644 index d6f5478f2..000000000 --- a/astrbot-sdk/tests/test_db_runtime.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.runtime.capability_router import CapabilityRouter - - -async def _call( - router: CapabilityRouter, - capability: str, - payload: dict[str, object], - *, - plugin_id: str = "test-plugin", -) -> dict[str, object]: - with caller_plugin_scope(plugin_id): - result = await router.execute( - capability, - payload, - stream=False, - cancel_token=object(), - request_id=f"{plugin_id}:{capability}", - ) - assert isinstance(result, dict) - return result - - -async def _stream( - router: CapabilityRouter, - capability: str, - payload: dict[str, object], - *, - plugin_id: str = "test-plugin", -): - with caller_plugin_scope(plugin_id): - result = await router.execute( - capability, - payload, - stream=True, - cancel_token=object(), - request_id=f"{plugin_id}:{capability}:stream", - ) - return result - - -@pytest.mark.asyncio -async def test_db_watch_returns_plugin_local_key_view( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - stream = await _stream(router, "db.watch", {"prefix": None}, plugin_id="plugin-a") - await _call( - router, - "db.set", - {"key": "user:1", "value": {"name": "Alice"}}, - plugin_id="plugin-a", - ) - - event = await anext(stream.iterator) - - assert event == { - "op": "set", - "key": "user:1", - "value": {"name": "Alice"}, - } - - -@pytest.mark.asyncio -async def test_db_watch_prefix_filters_within_plugin_scope( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - stream = await _stream(router, "db.watch", {"prefix": "user:"}, plugin_id="plugin-a") - await _call(router, "db.set", {"key": "config:1", "value": 1}, plugin_id="plugin-a") - await _call(router, "db.set", {"key": "user:2", "value": 2}, plugin_id="plugin-b") - await _call(router, "db.set", {"key": "user:1", "value": 3}, plugin_id="plugin-a") - - event = await anext(stream.iterator) - - assert event == {"op": "set", "key": "user:1", "value": 3} diff --git a/astrbot-sdk/tests/test_decorators_filter_guards.py b/astrbot-sdk/tests/test_decorators_filter_guards.py deleted file mode 100644 index 08d3a8be3..000000000 --- a/astrbot-sdk/tests/test_decorators_filter_guards.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import pytest - -from astrbot_sdk.decorators import ( - append_filter_meta, - get_handler_meta, - message_types, - platforms, -) -from astrbot_sdk.protocol.descriptors import ( - MessageTypeFilterSpec, - PlatformFilterSpec, -) - - -def test_platforms_rejects_existing_manual_platform_filter() -> None: - def handler() -> None: - return None - - append_filter_meta( - handler, - specs=[PlatformFilterSpec(platforms=["qq"])], - ) - - meta = get_handler_meta(handler) - assert meta is not None - assert meta.decorator_sources == {} - - with pytest.raises(ValueError, match="已有平台过滤器"): - platforms("wechat")(handler) - - -def test_message_types_rejects_existing_manual_message_type_filter() -> None: - def handler() -> None: - return None - - append_filter_meta( - handler, - specs=[MessageTypeFilterSpec(message_types=["group"])], - ) - - meta = get_handler_meta(handler) - assert meta is not None - assert meta.decorator_sources == {} - - with pytest.raises(ValueError, match="已有消息类型过滤器"): - message_types("private")(handler) diff --git a/astrbot-sdk/tests/test_http_runtime.py b/astrbot-sdk/tests/test_http_runtime.py deleted file mode 100644 index 6ecb6ad9a..000000000 --- a/astrbot-sdk/tests/test_http_runtime.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.runtime.capability_router import CapabilityRouter - - -async def _call( - router: CapabilityRouter, - capability: str, - payload: dict[str, object], - *, - plugin_id: str = "test-plugin", -) -> dict[str, object]: - with caller_plugin_scope(plugin_id): - result = await router.execute( - capability, - payload, - stream=False, - cancel_token=object(), - request_id=f"{plugin_id}:{capability}", - ) - assert isinstance(result, dict) - return result - - -@pytest.mark.asyncio -async def test_http_unregister_empty_methods_removes_all_for_route( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "http.register_api", - { - "route": "/demo", - "methods": ["GET", "POST"], - "handler_capability": "demo.handler", - "description": "demo", - }, - ) - await _call( - router, - "http.unregister_api", - {"route": "/demo", "methods": []}, - ) - - listed = await _call(router, "http.list_apis", {}) - - assert listed == {"apis": []} - - -@pytest.mark.asyncio -async def test_http_unregister_subset_preserves_other_methods( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "http.register_api", - { - "route": "/demo", - "methods": ["GET", "POST"], - "handler_capability": "demo.handler", - "description": "demo", - }, - ) - await _call( - router, - "http.unregister_api", - {"route": "/demo", "methods": ["POST"]}, - ) - - listed = await _call(router, "http.list_apis", {}) - - assert listed == { - "apis": [ - { - "route": "/demo", - "methods": ["GET"], - "handler_capability": "demo.handler", - "description": "demo", - "plugin_id": "test-plugin", - } - ] - } diff --git a/astrbot-sdk/tests/test_injected_params.py b/astrbot-sdk/tests/test_injected_params.py deleted file mode 100644 index caa92e14b..000000000 --- a/astrbot-sdk/tests/test_injected_params.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path -from types import SimpleNamespace - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from pydantic import BaseModel - -from astrbot_sdk._internal.command_model import resolve_command_model_param -from astrbot_sdk._internal.injected_params import ( - is_framework_injected_parameter, - legacy_arg_parameter_names, -) -from astrbot_sdk.conversation import ConversationSession -from astrbot_sdk.schedule import ScheduleContext -from astrbot_sdk.protocol.descriptors import CommandTrigger, HandlerDescriptor -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.loader import LoadedHandler, _build_param_specs - - -class _Payload(BaseModel): - name: str - - -def test_legacy_arg_parameter_names_excludes_injected_aliases() -> None: - def handler( - ctx, - conversation, - conv, - sched, - schedule, - name, - extra="fallback", - ) -> None: ... - - assert legacy_arg_parameter_names(handler) == ["name", "extra"] - - -def test_resolve_command_model_param_ignores_injected_aliases() -> None: - def handler(conversation, sched, payload: _Payload) -> None: ... - - resolved = resolve_command_model_param(handler) - - assert resolved is not None - assert resolved.name == "payload" - assert resolved.model_cls is _Payload - - -def test_is_framework_injected_parameter_supports_type_based_injection() -> None: - assert is_framework_injected_parameter("custom_conv", ConversationSession) - assert is_framework_injected_parameter("custom_schedule", ScheduleContext) - - -def test_loader_build_param_specs_excludes_injected_aliases() -> None: - def handler(conversation, schedule, name: str, count: int = 0) -> None: ... - - specs = _build_param_specs(handler) - - assert [spec.name for spec in specs] == ["name", "count"] - - -def test_handler_dispatcher_derive_args_skips_injected_aliases() -> None: - def handler(conversation, name, sched) -> None: ... - - loaded = LoadedHandler( - descriptor=HandlerDescriptor( - id="plugin.handler", - trigger=CommandTrigger(command="ping"), - ), - callable=handler, - owner=object(), - ) - dispatcher = HandlerDispatcher( - plugin_id="plugin", - peer=SimpleNamespace(), - handlers=[loaded], - ) - - args = dispatcher._derive_args(loaded, SimpleNamespace(text="ping alice")) - - assert args == {"name": "alice"} diff --git a/astrbot-sdk/tests/test_invocation_context_isolation.py b/astrbot-sdk/tests/test_invocation_context_isolation.py deleted file mode 100644 index 66b7c8c05..000000000 --- a/astrbot-sdk/tests/test_invocation_context_isolation.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -针对 caller_plugin_scope / invocation_context 的并发隔离单元测试。 -覆盖: - - 基本作用域绑定与清理 - - 嵌套作用域 - - 并发 Task 下 ContextVar 互不干扰 - - 作用域结束后正确 reset -""" - -from __future__ import annotations - -import asyncio -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from astrbot_sdk._internal.invocation_context import ( - bind_caller_plugin_id, - caller_plugin_scope, - current_caller_plugin_id, - reset_caller_plugin_id, -) - - -# ── 基本绑定与清理 ───────────────────────────────────────────────── - - -def test_default_is_none() -> None: - assert current_caller_plugin_id() is None - - -def test_scope_sets_and_resets() -> None: - assert current_caller_plugin_id() is None - with caller_plugin_scope("plugin_a"): - assert current_caller_plugin_id() == "plugin_a" - # 作用域结束后必须恢复为 None - assert current_caller_plugin_id() is None - - -def test_scope_resets_to_previous_value() -> None: - token = bind_caller_plugin_id("outer") - try: - assert current_caller_plugin_id() == "outer" - with caller_plugin_scope("inner"): - assert current_caller_plugin_id() == "inner" - assert current_caller_plugin_id() == "outer" - finally: - reset_caller_plugin_id(token) - - -def test_scope_with_none_clears_id() -> None: - token = bind_caller_plugin_id("plugin_x") - try: - with caller_plugin_scope(None): - assert current_caller_plugin_id() is None - assert current_caller_plugin_id() == "plugin_x" - finally: - reset_caller_plugin_id(token) - - -def test_empty_string_normalized_to_none() -> None: - token = bind_caller_plugin_id(" ") # 空白字符串 → None - try: - assert current_caller_plugin_id() is None - finally: - reset_caller_plugin_id(token) - - -# ── 嵌套作用域 ──────────────────────────────────────────────────── - - -def test_nested_scopes_restore_correctly() -> None: - with caller_plugin_scope("a"): - assert current_caller_plugin_id() == "a" - with caller_plugin_scope("b"): - assert current_caller_plugin_id() == "b" - with caller_plugin_scope("c"): - assert current_caller_plugin_id() == "c" - assert current_caller_plugin_id() == "b" - assert current_caller_plugin_id() == "a" - assert current_caller_plugin_id() is None - - -# ── 并发 Task 隔离 ──────────────────────────────────────────────── - - -def test_concurrent_tasks_do_not_share_context() -> None: - """不同 asyncio Task 中的 ContextVar 互不干扰。""" - - results: dict[str, str | None] = {} - - async def task_fn(plugin_id: str, delay: float) -> None: - with caller_plugin_scope(plugin_id): - await asyncio.sleep(delay) - results[plugin_id] = current_caller_plugin_id() - - async def run() -> None: - # 两个 Task 并发执行,delay 设置使它们交叉运行 - await asyncio.gather( - task_fn("plugin_alpha", 0.01), - task_fn("plugin_beta", 0.001), - ) - - asyncio.run(run()) - - assert results["plugin_alpha"] == "plugin_alpha" - assert results["plugin_beta"] == "plugin_beta" - - -def test_child_task_inherits_parent_context_but_isolated() -> None: - """子 Task 继承父 Task 的 ContextVar 快照,但修改不会影响父 Task。""" - - parent_values: list[str | None] = [] - child_values: list[str | None] = [] - - async def child_task() -> None: - # 子 Task 在父 Task 的 scope 内创建,继承 "parent_plugin" 快照 - child_values.append(current_caller_plugin_id()) - # 子 Task 内修改不应该影响父 Task - with caller_plugin_scope("child_plugin"): - child_values.append(current_caller_plugin_id()) - child_values.append(current_caller_plugin_id()) - - async def parent_task() -> None: - with caller_plugin_scope("parent_plugin"): - parent_values.append(current_caller_plugin_id()) - task = asyncio.create_task(child_task()) - await asyncio.sleep(0.01) - parent_values.append(current_caller_plugin_id()) - await task - parent_values.append(current_caller_plugin_id()) - - asyncio.run(parent_task()) - - # 子 Task 继承了父 Task 的初始值 - assert child_values[0] == "parent_plugin" - assert child_values[1] == "child_plugin" - assert child_values[2] == "parent_plugin" - - # 父 Task 全程不受子 Task 影响 - assert all(v == "parent_plugin" for v in parent_values) - - -def test_scope_exception_still_resets() -> None: - """作用域内抛出异常时,ContextVar 依然被正确 reset。""" - assert current_caller_plugin_id() is None - try: - with caller_plugin_scope("error_plugin"): - assert current_caller_plugin_id() == "error_plugin" - raise RuntimeError("intentional error") - except RuntimeError: - pass - assert current_caller_plugin_id() is None diff --git a/astrbot-sdk/tests/test_mcp_runtime.py b/astrbot-sdk/tests/test_mcp_runtime.py deleted file mode 100644 index 9cfef9cda..000000000 --- a/astrbot-sdk/tests/test_mcp_runtime.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -import pytest -from astrbot_sdk.testing import MockContext - -from tests.test_sdk.unit._mcp_contract import exercise_local_mcp_contract - - -class _MockMCPBackend: - def __init__(self, ctx: MockContext) -> None: - self._ctx = ctx - - async def get_server(self, name: str): - return await self._ctx.mcp.get_server(name) - - async def list_servers(self): - return await self._ctx.mcp.list_servers() - - async def enable_server(self, name: str): - return await self._ctx.mcp.enable_server(name) - - async def disable_server(self, name: str): - return await self._ctx.mcp.disable_server(name) - - async def wait_until_ready(self, name: str, *, timeout: float): - return await self._ctx.mcp.wait_until_ready(name, timeout=timeout) - - -def _local_server_payload(name: str, *, running: bool, delay: float = 0.0) -> dict: - return { - "name": name, - "scope": "local", - "active": True, - "running": running, - "config": { - "mock_tools": ["lookup"], - "mock_connect_delay": delay, - }, - "tools": ["lookup"] if running else [], - "errlogs": [], - "last_error": None, - } - - -@pytest.mark.asyncio -async def test_mock_context_mcp_local_contract_and_alias() -> None: - ctx = MockContext( - plugin_id="sdk-demo", - plugin_metadata={ - "local_mcp_servers": { - "demo": _local_server_payload("demo", running=True), - } - }, - ) - - assert ctx.mcp is ctx.mcp_manager - - await exercise_local_mcp_contract(_MockMCPBackend(ctx)) - - -@pytest.mark.asyncio -async def test_mock_context_mcp_wait_until_ready_success_and_timeout() -> None: - ctx = MockContext( - plugin_id="sdk-demo", - plugin_metadata={ - "local_mcp_servers": { - "demo": _local_server_payload("demo", running=False, delay=0.01), - "slow": _local_server_payload("slow", running=False, delay=0.2), - } - }, - ) - - ready = await ctx.mcp.wait_until_ready("demo", timeout=0.1) - assert ready.running is True - assert ready.tools == ["lookup"] - - with pytest.raises(TimeoutError): - await ctx.mcp.wait_until_ready("slow", timeout=0.01) - - -@pytest.mark.asyncio -async def test_mock_context_mcp_session_round_trip_and_tool_loop_isolation() -> None: - ctx = MockContext(plugin_id="sdk-demo") - - async with ctx.mcp.session( - "adhoc", - { - "mock_tools": ["inspect"], - "mock_tool_results": {"inspect": {"ok": True}}, - }, - timeout=0.1, - ) as session: - assert await session.list_tools() == ["inspect"] - assert await session.call_tool("inspect", {"x": 1}) == {"ok": True} - tool_loop = await ctx.tool_loop_agent(prompt="hello mcp") - assert "inspect" not in tool_loop.text - - assert ctx.router._mcp_session_store == {} - - -@pytest.mark.asyncio -async def test_mock_context_local_mcp_tools_are_plugin_scoped() -> None: - ctx_a = MockContext( - plugin_id="plugin-a", - plugin_metadata={ - "local_mcp_servers": { - "alpha": { - "name": "alpha", - "scope": "local", - "active": True, - "running": True, - "config": {"mock_tools": ["lookup"]}, - "tools": ["lookup"], - "errlogs": [], - "last_error": None, - } - } - }, - ) - ctx_b = MockContext( - plugin_id="plugin-b", - plugin_metadata={ - "local_mcp_servers": { - "beta": { - "name": "beta", - "scope": "local", - "active": True, - "running": True, - "config": {"mock_tools": ["lookup"]}, - "tools": ["lookup"], - "errlogs": [], - "last_error": None, - } - } - }, - ) - - resp_a = await ctx_a.tool_loop_agent(prompt="hello") - resp_b = await ctx_b.tool_loop_agent(prompt="hello") - - assert "mcp.alpha.lookup" in resp_a.text - assert "mcp.beta.lookup" not in resp_a.text - assert "mcp.beta.lookup" in resp_b.text - assert "mcp.alpha.lookup" not in resp_b.text - assert ctx_a.router._mcp_global_servers == {} - assert ctx_b.router._mcp_global_servers == {} - - -@pytest.mark.asyncio -async def test_mock_context_global_mcp_requires_ack_and_audits() -> None: - plain_ctx = MockContext(plugin_id="plain-plugin") - with pytest.raises(PermissionError): - await plain_ctx.mcp.register_global_server( - "global-demo", - {"mock_tools": ["inspect"]}, - ) - with pytest.raises(PermissionError): - await plain_ctx.mcp.list_global_servers() - with pytest.raises(PermissionError): - await plain_ctx.mcp.get_global_server("global-demo") - - ctx = MockContext( - plugin_id="privileged-plugin", - plugin_metadata={"acknowledge_global_mcp_risk": True}, - ) - record = await ctx.mcp.register_global_server( - "global-demo", - {"mock_tools": ["inspect"]}, - ) - - assert record.scope.value == "global" - assert record.running is True - assert [item.name for item in await ctx.mcp.list_global_servers()] == [ - "global-demo" - ] - assert (await ctx.mcp.get_global_server("global-demo")).name == "global-demo" - assert ctx.router._mcp_audit_logs == [ - { - "plugin_id": "privileged-plugin", - "action": "register", - "server_name": "global-demo", - "request_id": "local_0001", - } - ] diff --git a/astrbot-sdk/tests/test_memory_client.py b/astrbot-sdk/tests/test_memory_client.py deleted file mode 100644 index b5869a132..000000000 --- a/astrbot-sdk/tests/test_memory_client.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest -from astrbot_sdk.clients.memory import MemoryClient - - -class _FakeProxy: - def __init__(self, responses: dict[str, dict[str, Any]] | None = None) -> None: - self.responses = responses or {} - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call(self, name: str, payload: dict[str, Any]) -> dict[str, Any]: - self.calls.append((name, dict(payload))) - return dict(self.responses.get(name, {})) - - -@pytest.mark.asyncio -async def test_root_client_search_preserves_explicit_root_namespace() -> None: - proxy = _FakeProxy({"memory.search": {"items": []}}) - client = MemoryClient(proxy) # type: ignore[arg-type] - - await client.search("shared", namespace="", include_descendants=False) - - assert proxy.calls == [ - ( - "memory.search", - { - "query": "shared", - "mode": "auto", - "namespace": "", - "include_descendants": False, - }, - ) - ] - - -@pytest.mark.asyncio -async def test_root_client_search_omits_namespace_when_scope_is_unspecified() -> None: - proxy = _FakeProxy({"memory.search": {"items": []}}) - client = MemoryClient(proxy) # type: ignore[arg-type] - - await client.search("shared") - - assert proxy.calls == [ - ( - "memory.search", - { - "query": "shared", - "mode": "auto", - "include_descendants": True, - }, - ) - ] - - -@pytest.mark.asyncio -async def test_stats_returns_namespace_backend_fields() -> None: - proxy = _FakeProxy( - { - "memory.stats": { - "total_items": 3, - "total_bytes": 128, - "namespace": "users/alice", - "namespace_count": 2, - "fts_enabled": True, - "vector_backend": "faiss", - "vector_indexes": [{"provider_id": "embedding-1", "dirty": False}], - "plugin_id": "test-plugin", - "ttl_entries": 1, - } - } - ) - client = MemoryClient(proxy, namespace="users") # type: ignore[arg-type] - - stats = await client.stats(namespace="alice", include_descendants=False) - - assert proxy.calls == [ - ( - "memory.stats", - { - "include_descendants": False, - "namespace": "users/alice", - }, - ) - ] - assert stats == { - "total_items": 3, - "total_bytes": 128, - "namespace": "users/alice", - "namespace_count": 2, - "fts_enabled": True, - "vector_backend": "faiss", - "vector_indexes": [{"provider_id": "embedding-1", "dirty": False}], - "plugin_id": "test-plugin", - "ttl_entries": 1, - } diff --git a/astrbot-sdk/tests/test_memory_runtime.py b/astrbot-sdk/tests/test_memory_runtime.py deleted file mode 100644 index 96a8e5c71..000000000 --- a/astrbot-sdk/tests/test_memory_runtime.py +++ /dev/null @@ -1,415 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import astrbot_sdk._memory_backend as memory_backend_module -import pytest -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.runtime.capability_router import CapabilityRouter - - -async def _call( - router: CapabilityRouter, - capability: str, - payload: dict[str, object], - *, - plugin_id: str = "test-plugin", -) -> dict[str, object]: - with caller_plugin_scope(plugin_id): - result = await router.execute( - capability, - payload, - stream=False, - cancel_token=object(), - request_id=f"{plugin_id}:{capability}", - ) - assert isinstance(result, dict) - return result - - -def _memory_db_path(tmp_path: Path, plugin_id: str) -> Path: - return ( - tmp_path - / ".astrbot_sdk_testing" - / "plugin_data" - / plugin_id - / "memory" - / "memory.sqlite3" - ) - - -@pytest.mark.asyncio -async def test_memory_is_plugin_scoped_and_persistent( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - {"key": "profile", "value": {"content": "alice likes blue"}}, - plugin_id="plugin-a", - ) - await _call( - router, - "memory.save", - {"key": "profile", "value": {"content": "bob likes green"}}, - plugin_id="plugin-b", - ) - - profile_a = await _call( - router, - "memory.get", - {"key": "profile"}, - plugin_id="plugin-a", - ) - profile_b = await _call( - router, - "memory.get", - {"key": "profile"}, - plugin_id="plugin-b", - ) - - assert profile_a == {"value": {"content": "alice likes blue"}} - assert profile_b == {"value": {"content": "bob likes green"}} - assert _memory_db_path(tmp_path, "plugin-a").exists() - - restarted = CapabilityRouter() - persisted = await _call( - restarted, - "memory.get", - {"key": "profile"}, - plugin_id="plugin-a", - ) - assert persisted == {"value": {"content": "alice likes blue"}} - - -@pytest.mark.asyncio -async def test_memory_namespace_search_respects_descendants( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - { - "key": "profile", - "namespace": "users/alice", - "value": {"content": "alice likes blue"}, - }, - ) - await _call( - router, - "memory.save", - { - "key": "session-note", - "namespace": "users/alice/sessions/1", - "value": {"content": "alice asked about the sea"}, - }, - ) - await _call( - router, - "memory.save", - { - "key": "profile", - "namespace": "users/bob", - "value": {"content": "bob likes green"}, - }, - ) - - exact = await _call( - router, - "memory.search", - { - "query": "alice", - "namespace": "users/alice", - "include_descendants": False, - "mode": "keyword", - }, - ) - scoped = await _call( - router, - "memory.search", - { - "query": "alice", - "namespace": "users/alice", - "include_descendants": True, - "mode": "keyword", - }, - ) - - assert [(item["namespace"], item["key"]) for item in exact["items"]] == [ - ("users/alice", "profile") - ] - assert {(item["namespace"], item["key"]) for item in scoped["items"]} == { - ("users/alice", "profile"), - ("users/alice/sessions/1", "session-note"), - } - - -@pytest.mark.asyncio -async def test_memory_search_auto_falls_back_to_keyword_without_embedding_provider( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - router._active_provider_ids["embedding"] = None - - await _call( - router, - "memory.save", - {"key": "alpha-key", "value": {"content": "blue ocean memory"}}, - ) - - result = await _call(router, "memory.search", {"query": "alpha", "mode": "auto"}) - - assert [item["key"] for item in result["items"]] == ["alpha-key"] - assert result["items"][0]["match_type"] == "keyword" - - -@pytest.mark.asyncio -async def test_memory_vector_search_and_stats_report_vector_backend( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - {"key": "fruit-note", "value": {"content": "banana smoothie with mango"}}, - ) - await _call( - router, - "memory.save", - {"key": "ocean-note", "value": {"content": "waves on the blue ocean"}}, - ) - - result = await _call( - router, - "memory.search", - {"query": "banana smoothie", "mode": "vector", "limit": 1}, - ) - stats = await _call(router, "memory.stats", {}) - - assert len(result["items"]) == 1 - assert result["items"][0]["key"] == "fruit-note" - assert result["items"][0]["match_type"] == "vector" - assert stats["plugin_id"] == "test-plugin" - assert stats["total_items"] == 2 - assert stats["vector_backend"] in {"faiss", "exact"} - - -@pytest.mark.asyncio -async def test_memory_save_with_ttl_expires_across_restart( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - base_now = datetime(2026, 1, 1, tzinfo=timezone.utc) - monkeypatch.setattr(memory_backend_module, "_utcnow", lambda: base_now) - router = CapabilityRouter() - - await _call( - router, - "memory.save_with_ttl", - { - "key": "session", - "namespace": "users/alice/sessions/1", - "value": {"content": "active session"}, - "ttl_seconds": 60, - }, - ) - - result = await _call( - router, - "memory.get_many", - {"keys": ["session"], "namespace": "users/alice/sessions/1"}, - ) - assert result == { - "items": [{"key": "session", "value": {"content": "active session"}}] - } - - monkeypatch.setattr( - memory_backend_module, - "_utcnow", - lambda: base_now + timedelta(seconds=61), - ) - restarted = CapabilityRouter() - expired = await _call( - restarted, - "memory.get", - {"key": "session", "namespace": "users/alice/sessions/1"}, - ) - assert expired == {"value": None} - - -@pytest.mark.asyncio -async def test_memory_rejects_unsafe_plugin_id( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - with pytest.raises(AstrBotError) as exc_info: - await _call( - router, - "memory.save", - {"key": "profile", "value": {"content": "alice likes blue"}}, - plugin_id="../escape", - ) - - assert exc_info.value.code == "invalid_input" - assert "safe plugin_id" in exc_info.value.message - - -@pytest.mark.asyncio -async def test_memory_stats_can_scope_by_namespace( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - { - "key": "root-note", - "value": {"content": "top level"}, - }, - ) - await _call( - router, - "memory.save", - { - "key": "user-note", - "namespace": "users/alice", - "value": {"content": "alice memory"}, - }, - ) - await _call( - router, - "memory.save", - { - "key": "session-note", - "namespace": "users/alice/sessions/1", - "value": {"content": "session memory"}, - }, - ) - - scoped = await _call( - router, - "memory.stats", - {"namespace": "users/alice", "include_descendants": True}, - ) - - assert scoped["namespace"] == "users/alice" - assert scoped["total_items"] == 2 - assert scoped["namespace_count"] == 2 - assert scoped["fts_enabled"] in {True, False} - - -@pytest.mark.asyncio -async def test_memory_search_and_stats_can_target_root_namespace_exactly( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - {"key": "root-note", "value": {"content": "shared note at root"}}, - ) - await _call( - router, - "memory.save", - { - "key": "child-note", - "namespace": "users/alice", - "value": {"content": "shared note in child namespace"}, - }, - ) - - result = await _call( - router, - "memory.search", - { - "query": "shared note", - "namespace": "", - "include_descendants": False, - "mode": "keyword", - }, - ) - stats = await _call( - router, - "memory.stats", - {"namespace": "", "include_descendants": False}, - ) - - assert [(item.get("namespace"), item["key"]) for item in result["items"]] == [ - (None, "root-note") - ] - assert stats["namespace"] == "" - assert stats["total_items"] == 1 - - -@pytest.mark.asyncio -async def test_memory_namespace_scope_escapes_like_wildcards( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - router = CapabilityRouter() - - await _call( - router, - "memory.save", - { - "key": "safe", - "namespace": "team_1/room", - "value": {"content": "team scoped note"}, - }, - ) - await _call( - router, - "memory.save", - { - "key": "leak", - "namespace": "teamA1/room", - "value": {"content": "team scoped note"}, - }, - ) - - result = await _call( - router, - "memory.search", - { - "query": "team scoped", - "namespace": "team_1", - "include_descendants": True, - "mode": "keyword", - }, - ) - stats = await _call( - router, - "memory.stats", - {"namespace": "team_1", "include_descendants": True}, - ) - - assert [(item["namespace"], item["key"]) for item in result["items"]] == [ - ("team_1/room", "safe") - ] - assert stats["total_items"] == 1 diff --git a/astrbot-sdk/tests/test_message_components.py b/astrbot-sdk/tests/test_message_components.py deleted file mode 100644 index 8c640bffc..000000000 --- a/astrbot-sdk/tests/test_message_components.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from astrbot_sdk.message.components import File, Image, Record, Video - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("factory", "url", "prefix", "suffix"), - [ - (Image.fromURL, "https://example.com/test.jpg", "imgseg", ".jpg"), - (Record.fromURL, "https://example.com/test.dat", "recordseg", ".dat"), - (Video.fromURL, "https://example.com/test.mp4", "videoseg", ""), - ], -) -async def test_remote_media_download_uses_async_to_thread( - monkeypatch: pytest.MonkeyPatch, - factory, - url: str, - prefix: str, - suffix: str, -) -> None: - calls: list[tuple[object, tuple[object, ...]]] = [] - - async def fake_to_thread(func, *args): - calls.append((func, args)) - return str(Path("C:/tmp/downloaded.bin")) - - monkeypatch.setattr( - "astrbot_sdk.message.components.asyncio.to_thread", fake_to_thread - ) - - component = factory(url) - path = await component.convert_to_file_path() - - assert Path(path) == Path("C:/tmp/downloaded.bin") - assert len(calls) == 1 - _, args = calls[0] - assert args == (url, prefix, suffix) - - -@pytest.mark.asyncio -async def test_file_get_file_uses_async_to_thread_for_remote_download( - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[tuple[object, tuple[object, ...]]] = [] - - async def fake_to_thread(func, *args): - calls.append((func, args)) - return str(Path("C:/tmp/file-download.bin")) - - monkeypatch.setattr( - "astrbot_sdk.message.components.asyncio.to_thread", fake_to_thread - ) - - component = File(name="demo.bin", url="https://example.com/demo.bin") - path = await component.get_file() - - assert Path(path) == Path("C:/tmp/file-download.bin") - assert Path(component.file) == Path("C:/tmp/file-download.bin") - assert len(calls) == 1 - _, args = calls[0] - assert args == ("https://example.com/demo.bin", "fileseg", ".bin") diff --git a/astrbot-sdk/tests/test_message_history_runtime.py b/astrbot-sdk/tests/test_message_history_runtime.py deleted file mode 100644 index 2c9de3386..000000000 --- a/astrbot-sdk/tests/test_message_history_runtime.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -import pytest - -from astrbot_sdk._internal.testing_support import MockContext -from astrbot_sdk.message.components import Plain -from astrbot_sdk.message.session import MessageSession - - -def _session_store_key(session: MessageSession) -> str: - return f"{session.platform_id}:{session.message_type}:{session.session_id}" - - -@pytest.mark.asyncio -async def test_mock_context_message_history_round_trip_and_aliases() -> None: - ctx = MockContext(plugin_id="sdk-demo") - private_session = MessageSession( - platform_id="demo-platform", - message_type="private", - session_id="user-1", - ) - group_session = MessageSession( - platform_id="demo-platform", - message_type="group", - session_id="user-1", - ) - - assert ctx.message_history_manager is ctx.message_history - - first = await ctx.message_history.append( - private_session, - parts=[Plain("first", convert=False)], - sender={"sender_id": "sender-1", "sender_name": "Tester"}, - metadata={"source": "test"}, - idempotency_key="idem-1", - ) - repeated = await ctx.message_history.append( - private_session, - parts=[Plain("first", convert=False)], - sender={"sender_id": "sender-1", "sender_name": "Tester"}, - metadata={"source": "test"}, - idempotency_key="idem-1", - ) - second = await ctx.message_history.append( - private_session, - parts=[Plain("second", convert=False)], - sender={"sender_id": "sender-2", "sender_name": "Tester 2"}, - ) - third = await ctx.message_history.append( - private_session, - parts=[Plain("third", convert=False)], - sender={"sender_id": "sender-3", "sender_name": "Tester 3"}, - ) - group_record = await ctx.message_history.append( - group_session, - parts=[Plain("group only", convert=False)], - sender={"sender_id": "group-sender", "sender_name": "Group Tester"}, - ) - - assert repeated.id == first.id - assert group_record.session.message_type == "group" - - first_page = await ctx.message_history.list(private_session, limit=2) - assert [record.id for record in first_page.records] == [third.id, second.id] - assert first_page.next_cursor == str(second.id) - assert first_page.total == 3 - assert first_page.records[0].parts[0].text == "third" - - second_page = await ctx.message_history.list( - private_session, - cursor=first_page.next_cursor, - limit=2, - ) - assert [record.id for record in second_page.records] == [first.id] - assert second_page.next_cursor is None - - fetched = await ctx.message_history.get(private_session, second.id) - assert fetched is not None - assert fetched.sender.sender_id == "sender-2" - assert fetched.metadata == {} - assert fetched.parts[0].text == "second" - - group_page = await ctx.message_history.list(group_session, limit=10) - assert [record.id for record in group_page.records] == [group_record.id] - - store = ctx.router._message_history_store[_session_store_key(private_session)] - timestamps = { - first.id: "2026-03-20T00:00:00+00:00", - second.id: "2026-03-21T00:00:00+00:00", - third.id: "2026-03-22T00:00:00+00:00", - } - for record in store: - stamped = timestamps.get(int(record["id"])) - if stamped is None: - continue - record["created_at"] = stamped - record["updated_at"] = stamped - - deleted_before = await ctx.message_history.delete_before( - private_session, - before=datetime(2026, 3, 21, 0, 0, tzinfo=timezone.utc), - ) - assert deleted_before == 1 - remaining_after_before = await ctx.message_history.list(private_session, limit=10) - assert [record.id for record in remaining_after_before.records] == [ - third.id, - second.id, - ] - - deleted_after = await ctx.message_history.delete_after( - private_session, - after=datetime(2026, 3, 21, 12, 0, tzinfo=timezone.utc), - ) - assert deleted_after == 1 - remaining_after_after = await ctx.message_history.list(private_session, limit=10) - assert [record.id for record in remaining_after_after.records] == [second.id] - - deleted_all = await ctx.message_history.delete_all(private_session) - assert deleted_all == 1 - assert (await ctx.message_history.list(private_session, limit=10)).records == [] - assert [ - record.id - for record in (await ctx.message_history.list(group_session, limit=10)).records - ] == [group_record.id] diff --git a/astrbot-sdk/tests/test_plugin_ids.py b/astrbot-sdk/tests/test_plugin_ids.py deleted file mode 100644 index f23e56788..000000000 --- a/astrbot-sdk/tests/test_plugin_ids.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from astrbot_sdk._internal.plugin_ids import resolve_plugin_data_dir, validate_plugin_id - - -def test_validate_plugin_id_accepts_safe_identifiers() -> None: - assert validate_plugin_id("plugin-1.alpha_beta") == "plugin-1.alpha_beta" - - -@pytest.mark.parametrize( - "plugin_id", - ["", "../escape", "bad/name", r"bad\\name", "bad.", "CON"], -) -def test_validate_plugin_id_rejects_unsafe_values(plugin_id: str) -> None: - with pytest.raises(ValueError): - validate_plugin_id(plugin_id) - - -def test_resolve_plugin_data_dir_stays_within_root(tmp_path: Path) -> None: - resolved = resolve_plugin_data_dir(tmp_path, "plugin-a") - - assert resolved == tmp_path.resolve() / "plugin-a" diff --git a/astrbot-sdk/tests/test_plugin_logger.py b/astrbot-sdk/tests/test_plugin_logger.py deleted file mode 100644 index 71c76dd1f..000000000 --- a/astrbot-sdk/tests/test_plugin_logger.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import re - -from astrbot_sdk._internal.plugin_logger import PluginLogger - -_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - - -class _CapturingLogger: - def __init__(self) -> None: - self.calls: list[dict[str, object]] = [] - self._current_opt: dict[str, object] = {} - - def bind(self, **_kwargs): - return self - - def opt(self, *args, **kwargs): - self._current_opt = dict(kwargs) - return self - - def log(self, level, message, *args, **kwargs) -> None: - self.calls.append( - { - "level": level, - "message": message, - "args": args, - "kwargs": kwargs, - "opt": dict(self._current_opt), - } - ) - self._current_opt = {} - - -def _strip_ansi(text: str) -> str: - return _ANSI_RE.sub("", text) - - -def test_plugin_logger_formats_like_core_console(monkeypatch) -> None: - logger = _CapturingLogger() - plugin_logger = PluginLogger(plugin_id="ai_girlfriend", logger=logger) - monkeypatch.setattr( - plugin_logger, - "_caller_info", - lambda: ("D:/repo/data/sdk_plugins/ai_girlfriend/gf_plugin.py", 321), - ) - - plugin_logger.info("hello {}", "world") - - assert len(logger.calls) == 1 - call = logger.calls[0] - assert call["level"] == "INFO" - assert call["opt"] == {"raw": True} - assert re.match( - r"^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[Plug\] \[INFO\] " - r"\[ai_girlfriend\.gf_plugin:321\]: hello world\n$", - _strip_ansi(str(call["message"])), - ) - - -def test_plugin_logger_uses_core_tag_for_sdk_internal_paths(monkeypatch) -> None: - logger = _CapturingLogger() - plugin_logger = PluginLogger(plugin_id="ai_girlfriend", logger=logger) - monkeypatch.setattr( - plugin_logger, - "_caller_info", - lambda: ("D:/repo/astrbot-sdk/src/astrbot_sdk/context.py", 88), - ) - - plugin_logger.warning("watch {}", "out") - - assert len(logger.calls) == 1 - call = logger.calls[0] - assert call["level"] == "WARNING" - assert call["opt"] == {"raw": True} - rendered = _strip_ansi(str(call["message"])) - assert "[Core] [WARN]" in rendered - assert "[astrbot_sdk.context:88]: watch out\n" in rendered diff --git a/astrbot-sdk/tests/test_protocol_stdout.py b/astrbot-sdk/tests/test_protocol_stdout.py deleted file mode 100644 index ac793f518..000000000 --- a/astrbot-sdk/tests/test_protocol_stdout.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import asyncio -import io -import os -from pathlib import Path - -from click.testing import CliRunner - -from astrbot_sdk import cli - - -class _FakeStream(io.StringIO): - def __init__(self, *, is_tty: bool) -> None: - super().__init__() - self._is_tty = is_tty - - def isatty(self) -> bool: - return self._is_tty - - -def test_resolve_protocol_stdout_defaults_to_silent_on_tty(monkeypatch) -> None: - fake_stdout = _FakeStream(is_tty=True) - monkeypatch.setattr("sys.stdout", fake_stdout) - - transport_stdout, opened_stdout = cli._resolve_protocol_stdout(None) - - assert opened_stdout is not None - assert transport_stdout is opened_stdout - assert getattr(transport_stdout, "name", None) == os.devnull - opened_stdout.close() - - -def test_resolve_protocol_stdout_defaults_to_console_when_stdout_is_piped( - monkeypatch, -) -> None: - fake_stdout = _FakeStream(is_tty=False) - monkeypatch.setattr("sys.stdout", fake_stdout) - - transport_stdout, opened_stdout = cli._resolve_protocol_stdout(None) - - assert transport_stdout is fake_stdout - assert opened_stdout is None - - -def test_resolve_protocol_stdout_supports_file_path( - monkeypatch, tmp_path: Path -) -> None: - fake_stdout = _FakeStream(is_tty=True) - output_path = tmp_path / "protocol.log" - monkeypatch.setattr("sys.stdout", fake_stdout) - - transport_stdout, opened_stdout = cli._resolve_protocol_stdout(str(output_path)) - - assert opened_stdout is not None - assert transport_stdout is opened_stdout - assert getattr(transport_stdout, "name", None) == str(output_path) - opened_stdout.close() - - -def test_run_command_resolves_protocol_stdout_to_stream( - monkeypatch, tmp_path: Path -) -> None: - captured: dict[str, object] = {} - - async def fake_run_supervisor(*, plugins_dir: Path, stdout=None, **_) -> None: - captured["plugins_dir"] = plugins_dir - captured["stdout_name"] = getattr(stdout, "name", None) - - def fake_run_async_entrypoint(entrypoint, **_) -> None: - asyncio.run(entrypoint) - - monkeypatch.setattr(cli, "run_supervisor", fake_run_supervisor) - monkeypatch.setattr(cli, "_run_async_entrypoint", fake_run_async_entrypoint) - - runner = CliRunner() - result = runner.invoke( - cli.cli, - ["run", "--plugins-dir", str(tmp_path), "--protocol-stdout", "silent"], - ) - - assert result.exit_code == 0 - assert captured == { - "plugins_dir": tmp_path, - "stdout_name": os.devnull, - } - - -def test_worker_command_resolves_protocol_stdout_to_stream( - monkeypatch, tmp_path: Path -) -> None: - captured: dict[str, object] = {} - plugin_dir = tmp_path / "plugin" - plugin_dir.mkdir() - output_path = tmp_path / "worker-protocol.log" - - async def fake_run_plugin_worker( - *, - plugin_dir: Path | None = None, - group_metadata: Path | None = None, - stdout=None, - **_, - ) -> None: - captured["plugin_dir"] = plugin_dir - captured["group_metadata"] = group_metadata - captured["stdout_name"] = getattr(stdout, "name", None) - - def fake_run_async_entrypoint(entrypoint, **_) -> None: - asyncio.run(entrypoint) - - monkeypatch.setattr(cli, "run_plugin_worker", fake_run_plugin_worker) - monkeypatch.setattr(cli, "_run_async_entrypoint", fake_run_async_entrypoint) - - runner = CliRunner() - result = runner.invoke( - cli.cli, - [ - "worker", - "--plugin-dir", - str(plugin_dir), - "--protocol-stdout", - str(output_path), - ], - ) - - assert result.exit_code == 0 - assert captured == { - "plugin_dir": plugin_dir, - "group_metadata": None, - "stdout_name": str(output_path), - } diff --git a/astrbot-sdk/tests/test_provider_client_context_regressions.py b/astrbot-sdk/tests/test_provider_client_context_regressions.py deleted file mode 100644 index 087c58f30..000000000 --- a/astrbot-sdk/tests/test_provider_client_context_regressions.py +++ /dev/null @@ -1,409 +0,0 @@ -from __future__ import annotations - -import asyncio -from copy import deepcopy -from dataclasses import dataclass -from types import SimpleNamespace -from typing import Any - -import pytest - -from astrbot_sdk._internal.testing_support import MockContext -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.clients.platform import PlatformStatus -from astrbot_sdk.clients.provider import ProviderManagerClient -from astrbot_sdk.context import PlatformCompatFacade -from astrbot_sdk.llm.entities import ProviderType - - -async def _wait_until( - predicate, - *, - timeout: float = 0.2, -) -> None: - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - if predicate(): - return - await asyncio.sleep(0) - raise AssertionError("condition was not satisfied before timeout") - - -class _HookLogger: - def __init__(self) -> None: - self.debug_calls: list[tuple[str, str]] = [] - self.exception_calls: list[tuple[str, str]] = [] - - def debug(self, message: str, plugin_id: str) -> None: - self.debug_calls.append((message, plugin_id)) - - def exception(self, message: str, plugin_id: str) -> None: - self.exception_calls.append((message, plugin_id)) - - -@dataclass(slots=True) -class _CapabilityDescriptor: - supports_stream: bool | None = False - - -class _ProviderMutationPeer: - def __init__(self) -> None: - self.remote_peer = object() - self.remote_capability_map = { - "provider.manager.create": _CapabilityDescriptor(), - "provider.manager.load": _CapabilityDescriptor(), - "provider.manager.update": _CapabilityDescriptor(), - "provider.manager.get_merged_provider_config": _CapabilityDescriptor(), - } - self.stored_config = {"id": "provider-1", "model": "original-model"} - - async def invoke( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, Any]: - assert not stream - if capability in {"provider.manager.create", "provider.manager.load"}: - provider_config = payload["provider_config"] - assert isinstance(provider_config, dict) - provider_config["id"] = "mutated-by-peer" - provider_config["model"] = "mutated-model" - return { - "provider": { - "id": "provider-1", - "model": "created-model", - "type": "mock", - "provider_type": "chat_completion", - "loaded": True, - "enabled": True, - "provider_source_id": None, - } - } - if capability == "provider.manager.update": - new_config = payload["new_config"] - assert isinstance(new_config, dict) - new_config["id"] = "mutated-by-peer" - new_config["model"] = "mutated-model" - return { - "provider": { - "id": "provider-1", - "model": "updated-model", - "type": "mock", - "provider_type": "chat_completion", - "loaded": True, - "enabled": True, - "provider_source_id": None, - } - } - if capability == "provider.manager.get_merged_provider_config": - return {"config": self.stored_config} - raise AssertionError(f"unexpected capability: {capability}") - - async def invoke_stream( - self, - capability: str, - payload: dict[str, Any], - *, - request_id: str | None = None, - include_completed: bool = False, - ): - raise AssertionError(f"unexpected stream capability: {capability}") - - -class _ControlledPlatformProxy: - def __init__( - self, - *, - snapshots: list[dict[str, Any]], - cleared_snapshot: dict[str, Any] | None = None, - ) -> None: - self._snapshots = [dict(item) for item in snapshots] - self._cleared_snapshot = ( - dict(cleared_snapshot) if isinstance(cleared_snapshot, dict) else None - ) - self.call_order: list[str] = [] - self.get_by_id_calls = 0 - self.clear_errors_calls = 0 - self.first_get_started = asyncio.Event() - self.release_first_get = asyncio.Event() - self._cleared = False - - async def call(self, capability: str, payload: dict[str, Any]) -> dict[str, Any]: - self.call_order.append(capability) - if capability == "platform.manager.get_by_id": - call_index = self.get_by_id_calls - self.get_by_id_calls += 1 - if call_index == 0: - self.first_get_started.set() - await self.release_first_get.wait() - if self._cleared and self._cleared_snapshot is not None: - snapshot = self._cleared_snapshot - else: - snapshot = self._snapshots[min(call_index, len(self._snapshots) - 1)] - return {"platform": dict(snapshot)} - if capability == "platform.manager.clear_errors": - self.clear_errors_calls += 1 - self._cleared = True - return {} - raise AssertionError(f"unexpected capability: {capability}") - - -@pytest.mark.asyncio -async def test_provider_change_hook_receives_events_and_unregisters_cleanly() -> None: - ctx = MockContext(plugin_metadata={"reserved": True}) - received: list[tuple[str, ProviderType, str | None]] = [] - event_received = asyncio.Event() - - async def callback( - provider_id: str, - provider_type: ProviderType, - umo: str | None, - ) -> None: - received.append((provider_id, provider_type, umo)) - event_received.set() - - task = await ctx.provider_manager.register_provider_change_hook(callback) - await _wait_until(lambda: len(ctx.router._provider_change_subscriptions) == 1) - - ctx.router.emit_provider_change( - "mock-embedding-provider", - ProviderType.EMBEDDING.value, - "mock:session:user", - ) - await asyncio.wait_for(event_received.wait(), timeout=0.2) - - assert received == [ - ( - "mock-embedding-provider", - ProviderType.EMBEDDING, - "mock:session:user", - ) - ] - - await ctx.provider_manager.unregister_provider_change_hook(task) - await _wait_until(lambda: not ctx.router._provider_change_subscriptions) - assert task.cancelled() - assert not ctx.provider_manager._change_hook_tasks - - ctx.router.emit_provider_change( - "mock-rerank-provider", - ProviderType.RERANK.value, - None, - ) - await asyncio.sleep(0) - assert received == [ - ( - "mock-embedding-provider", - ProviderType.EMBEDDING, - "mock:session:user", - ) - ] - - -@pytest.mark.asyncio -async def test_provider_change_hook_task_cancellation_cleans_up_and_logs_once() -> None: - logger = _HookLogger() - ctx = MockContext(plugin_metadata={"reserved": True}, logger=logger) - - task = await ctx.provider_manager.register_provider_change_hook(lambda *_args: None) - await _wait_until(lambda: len(ctx.router._provider_change_subscriptions) == 1) - - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - await _wait_until(lambda: not ctx.router._provider_change_subscriptions) - assert not ctx.provider_manager._change_hook_tasks - assert logger.debug_calls == [ - ("Provider change hook cancelled: plugin_id={}", "test-plugin") - ] - assert logger.exception_calls == [] - - -@pytest.mark.asyncio -async def test_platform_compat_refresh_serializes_concurrent_state_updates() -> None: - proxy = _ControlledPlatformProxy( - snapshots=[ - { - "id": "mock-platform", - "name": "First Snapshot", - "type": "mock", - "status": "error", - "errors": [ - { - "message": "first error", - "timestamp": "2026-03-20T00:00:00+00:00", - "traceback": None, - } - ], - "last_error": { - "message": "first error", - "timestamp": "2026-03-20T00:00:00+00:00", - "traceback": None, - }, - "unified_webhook": False, - }, - { - "id": "mock-platform", - "name": "Second Snapshot", - "type": "mock-updated", - "status": "running", - "errors": [], - "last_error": None, - "unified_webhook": True, - }, - ] - ) - facade = PlatformCompatFacade( - _ctx=SimpleNamespace(_proxy=proxy), - id="mock-platform", - name="Initial Snapshot", - type="mock", - ) - - first = asyncio.create_task(facade.refresh()) - await asyncio.wait_for(proxy.first_get_started.wait(), timeout=0.2) - - second = asyncio.create_task(facade.refresh()) - await asyncio.sleep(0) - assert proxy.get_by_id_calls == 1 - - proxy.release_first_get.set() - await asyncio.gather(first, second) - - assert proxy.call_order == [ - "platform.manager.get_by_id", - "platform.manager.get_by_id", - ] - assert facade.name == "Second Snapshot" - assert facade.type == "mock-updated" - assert facade.status == PlatformStatus.RUNNING - assert facade.errors == [] - assert facade.last_error is None - assert facade.unified_webhook is True - - -@pytest.mark.asyncio -async def test_platform_compat_clear_errors_waits_for_inflight_refresh() -> None: - proxy = _ControlledPlatformProxy( - snapshots=[ - { - "id": "mock-platform", - "name": "Errored Platform", - "type": "mock", - "status": "error", - "errors": [ - { - "message": "boom", - "timestamp": "2026-03-20T00:00:00+00:00", - "traceback": "trace", - } - ], - "last_error": { - "message": "boom", - "timestamp": "2026-03-20T00:00:00+00:00", - "traceback": "trace", - }, - "unified_webhook": False, - } - ], - cleared_snapshot={ - "id": "mock-platform", - "name": "Recovered Platform", - "type": "mock", - "status": "running", - "errors": [], - "last_error": None, - "unified_webhook": False, - }, - ) - facade = PlatformCompatFacade( - _ctx=SimpleNamespace(_proxy=proxy), - id="mock-platform", - name="Initial Snapshot", - type="mock", - ) - - refresh_task = asyncio.create_task(facade.refresh()) - await asyncio.wait_for(proxy.first_get_started.wait(), timeout=0.2) - - clear_task = asyncio.create_task(facade.clear_errors()) - await asyncio.sleep(0) - assert proxy.clear_errors_calls == 0 - - proxy.release_first_get.set() - await asyncio.gather(refresh_task, clear_task) - - assert proxy.call_order == [ - "platform.manager.get_by_id", - "platform.manager.clear_errors", - "platform.manager.get_by_id", - ] - assert facade.name == "Recovered Platform" - assert facade.status == PlatformStatus.RUNNING - assert facade.errors == [] - assert facade.last_error is None - - -@pytest.mark.asyncio -async def test_provider_manager_methods_copy_caller_supplied_config_dicts() -> None: - peer = _ProviderMutationPeer() - manager = ProviderManagerClient( - CapabilityProxy(peer), - plugin_id="test-plugin", - logger=None, - ) - - create_config = { - "id": "provider-create", - "model": "create-model", - "type": "mock", - "provider_type": ProviderType.CHAT_COMPLETION.value, - } - load_config = { - "id": "provider-load", - "model": "load-model", - "type": "mock", - "provider_type": ProviderType.CHAT_COMPLETION.value, - } - update_config = { - "id": "provider-update", - "model": "update-model", - "type": "mock", - "provider_type": ProviderType.CHAT_COMPLETION.value, - } - - create_snapshot = deepcopy(create_config) - load_snapshot = deepcopy(load_config) - update_snapshot = deepcopy(update_config) - - await manager.create_provider(create_config) - await manager.load_provider(load_config) - await manager.update_provider("provider-origin", update_config) - - assert create_config == create_snapshot - assert load_config == load_snapshot - assert update_config == update_snapshot - - -@pytest.mark.asyncio -async def test_provider_manager_get_merged_provider_config_returns_detached_dict() -> ( - None -): - peer = _ProviderMutationPeer() - manager = ProviderManagerClient( - CapabilityProxy(peer), - plugin_id="test-plugin", - logger=None, - ) - - config = await manager.get_merged_provider_config("provider-1") - assert config == {"id": "provider-1", "model": "original-model"} - - assert config is not peer.stored_config - config["model"] = "changed-by-caller" - assert peer.stored_config == {"id": "provider-1", "model": "original-model"} diff --git a/astrbot-sdk/tests/test_request_id_overlay_mapping.py b/astrbot-sdk/tests/test_request_id_overlay_mapping.py deleted file mode 100644 index f1bbd1e5d..000000000 --- a/astrbot-sdk/tests/test_request_id_overlay_mapping.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -import pytest - -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.testing import PluginHarness - - -def _write_overlay_test_plugin(plugin_dir: Path) -> None: - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - """ -_schema_version: 2 -name: overlay_test_plugin -author: tests -version: 1.0.0 -desc: request overlay regression tests - -runtime: - python: "3.12" - -components: - - class: main:OverlayPlugin -""".strip() - + "\n", - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "main.py").write_text( - """ -from astrbot_sdk import Context, MessageEvent, ScheduleContext, Star -from astrbot_sdk.decorators import on_event, on_schedule - - -class OverlayPlugin(Star): - @on_schedule(interval_seconds=60) - async def scheduled(self, ctx: Context, schedule: ScheduleContext) -> None: - applied = await ctx.registry.set_handler_whitelist(["alpha", "beta"]) - current = await ctx.registry.get_handler_whitelist() - await ctx.platform.send_by_id( - "test", - "schedule-target", - f"{','.join(applied or [])}|{','.join(current or []) if current else 'none'}", - ) - - @on_event("llm_request") - async def llm_overlay(self, event: MessageEvent) -> None: - requested = await event.request_llm() - current = await event.should_call_llm() - await event.reply(f"{requested}:{current}") -""".lstrip(), - encoding="utf-8", - ) - - -@pytest.mark.asyncio -async def test_schedule_handler_preserves_request_overlay_state(tmp_path: Path) -> None: - plugin_dir = tmp_path / "overlay_test_plugin" - _write_overlay_test_plugin(plugin_dir) - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - payload = harness.build_event_payload( - text="", - event_type="schedule", - request_id="req-schedule-1", - ) - payload["schedule"] = { - "schedule_id": "schedule-1", - "plugin_id": "overlay_test_plugin", - "handler_id": "overlay_test_plugin:scheduled", - "trigger_kind": "interval", - "interval_seconds": 60, - } - - records = await harness.dispatch_event(payload, request_id="req-schedule-1") - - assert len(records) == 1 - assert records[0].kind == "chain" - assert records[0].session == "test:private:schedule-target" - assert records[0].chain is not None - assert records[0].chain[0]["data"]["text"] == "alpha,beta|alpha,beta" - - -@pytest.mark.asyncio -async def test_non_message_event_preserves_request_overlay_state( - tmp_path: Path, -) -> None: - plugin_dir = tmp_path / "overlay_test_plugin" - _write_overlay_test_plugin(plugin_dir) - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - payload = harness.build_event_payload( - text="trigger llm overlay", - event_type="llm_request", - request_id="req-llm-1", - ) - - records = await harness.dispatch_event(payload, request_id="req-llm-1") - - assert len(records) == 1 - assert records[0].kind == "text" - assert records[0].text == "True:True" - - -class _RecordingPeer: - def __init__(self) -> None: - self.remote_peer = None - self.remote_capability_map: dict[str, Any] = {} - self.calls: list[tuple[str, dict[str, Any], str | None]] = [] - - async def invoke( - self, - capability: str, - payload: dict[str, Any], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, Any]: - self.calls.append((capability, dict(payload), request_id)) - return {"ok": True, "stream": stream} - - -@pytest.mark.asyncio -async def test_capability_proxy_keeps_transport_ids_unique_while_forwarding_request_scope() -> ( - None -): - peer = _RecordingPeer() - proxy = CapabilityProxy( - peer, - caller_plugin_id="overlay_test_plugin", - request_scope_id="req-parent-1", - ) - - await asyncio.gather( - proxy.call("system.event.llm.get_state", {}), - proxy.call("system.event.result.get", {}), - proxy.call("platform.send", {"session": "test:private:user-1", "text": "hi"}), - ) - - assert peer.calls == [ - ( - "system.event.llm.get_state", - {"_request_scope_id": "req-parent-1"}, - None, - ), - ( - "system.event.result.get", - {"_request_scope_id": "req-parent-1"}, - None, - ), - ( - "platform.send", - {"session": "test:private:user-1", "text": "hi"}, - None, - ), - ] diff --git a/astrbot-sdk/tests/test_runtime_loader_regressions.py b/astrbot-sdk/tests/test_runtime_loader_regressions.py deleted file mode 100644 index 748521421..000000000 --- a/astrbot-sdk/tests/test_runtime_loader_regressions.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import importlib -import shutil -import sys -from contextlib import contextmanager -from pathlib import Path -from typing import Iterator - -from astrbot_sdk.runtime.loader import ( - discover_plugins, - load_plugin, - load_plugin_spec, - validate_plugin_spec, -) - - -def _write_plugin( - plugin_dir: Path, - *, - plugin_name: str, - class_name: str, - main_source: str, - extra_files: dict[str, str] | None = None, - write_requirements: bool = True, -) -> None: - plugin_dir.mkdir(parents=True, exist_ok=True) - python_version = f"{sys.version_info.major}.{sys.version_info.minor}" - (plugin_dir / "plugin.yaml").write_text( - f""" -_schema_version: 2 -name: {plugin_name} -author: tests -version: 1.0.0 -desc: loader regression tests - -runtime: - python: "{python_version}" - -components: - - class: main:{class_name} -""".strip() - + "\n", - encoding="utf-8", - ) - if write_requirements: - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "main.py").write_text(main_source.lstrip(), encoding="utf-8") - - for relative_path, content in (extra_files or {}).items(): - target = plugin_dir / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - -def _load_first_instance(plugin_dir: Path): - plugin = load_plugin_spec(plugin_dir) - validate_plugin_spec(plugin) - loaded = load_plugin(plugin) - assert loaded.instances - return loaded.instances[0] - - -def _purge_module_roots(*roots: str) -> None: - for root in {item for item in roots if item}: - for module_name in list(sys.modules): - if module_name == root or module_name.startswith(f"{root}."): - sys.modules.pop(module_name, None) - - -@contextmanager -def _preserve_import_state(*module_roots: str) -> Iterator[None]: - original_path = list(sys.path) - original_modules = { - name: module - for name, module in sys.modules.items() - if any(name == root or name.startswith(f"{root}.") for root in module_roots) - } - try: - yield - finally: - sys.path[:] = original_path - _purge_module_roots(*module_roots) - sys.modules.update(original_modules) - importlib.invalidate_caches() - - -def test_load_plugin_reloads_same_path_after_source_change(tmp_path: Path) -> None: - plugin_dir = tmp_path / "reload_plugin" - _write_plugin( - plugin_dir, - plugin_name="reload_plugin", - class_name="ReloadPlugin", - main_source=""" -from astrbot_sdk import Star -from support.value import CURRENT_VALUE - - -class ReloadPlugin(Star): - value = CURRENT_VALUE -""", - extra_files={ - "support/__init__.py": "", - "support/value.py": 'CURRENT_VALUE = "v1"\n', - }, - ) - - with _preserve_import_state("main", "support"): - first = _load_first_instance(plugin_dir) - assert first.value == "v1" - - (plugin_dir / "support" / "value.py").write_text( - 'CURRENT_VALUE = "v2"\n', - encoding="utf-8", - ) - - second = _load_first_instance(plugin_dir) - assert second.value == "v2" - assert second.__class__ is not first.__class__ - assert ( - Path(sys.modules["main"].__file__).resolve() - == (plugin_dir / "main.py").resolve() - ) - assert ( - Path(sys.modules["support.value"].__file__).resolve() - == (plugin_dir / "support" / "value.py").resolve() - ) - - -def test_load_plugin_prefers_target_plugin_dir_for_generic_main_module( - tmp_path: Path, -) -> None: - foreign_dir = tmp_path / "foreign_main" - foreign_dir.mkdir(parents=True, exist_ok=True) - (foreign_dir / "main.py").write_text( - """ -from astrbot_sdk import Star - - -class SharedPlugin(Star): - source = "foreign" -""".lstrip(), - encoding="utf-8", - ) - - plugin_dir = tmp_path / "generic_main_plugin" - _write_plugin( - plugin_dir, - plugin_name="generic_main_plugin", - class_name="SharedPlugin", - main_source=""" -from astrbot_sdk import Star - - -class SharedPlugin(Star): - source = "plugin" -""", - ) - - with _preserve_import_state("main"): - sys.path.insert(0, str(foreign_dir.resolve())) - sys.path.append(str(plugin_dir.resolve())) - - _purge_module_roots("main") - __import__("main") - assert ( - Path(sys.modules["main"].__file__).resolve() - == (foreign_dir / "main.py").resolve() - ) - - instance = _load_first_instance(plugin_dir) - - assert instance.source == "plugin" - assert sys.path[0] == str(plugin_dir.resolve()) - assert ( - Path(sys.modules["main"].__file__).resolve() - == (plugin_dir / "main.py").resolve() - ) - - -def test_load_plugin_cleans_stale_bytecode_from_copied_fixture(tmp_path: Path) -> None: - fixture_source = tmp_path / "fixture_source" - _write_plugin( - fixture_source, - plugin_name="copied_fixture_plugin", - class_name="FixturePlugin", - main_source=""" -from astrbot_sdk import Star - - -class FixturePlugin(Star): - value = "fresh" -""", - ) - - cache_tag = sys.implementation.cache_tag or "cpython" - stale_main_pyc = fixture_source / "__pycache__" / f"main.{cache_tag}.pyc" - stale_main_pyc.parent.mkdir(parents=True, exist_ok=True) - stale_main_pyc.write_bytes(b"stale main bytecode") - - stale_nested_pyc = ( - fixture_source / "nested" / "__pycache__" / f"helper.{cache_tag}.pyc" - ) - stale_nested_pyc.parent.mkdir(parents=True, exist_ok=True) - stale_nested_pyc.write_bytes(b"stale nested bytecode") - - stale_orphan_pyc = fixture_source / "orphan.pyc" - stale_orphan_pyc.write_bytes(b"stale orphan bytecode") - - copied_fixture = tmp_path / "copied_fixture" - shutil.copytree(fixture_source, copied_fixture) - - with _preserve_import_state("main"): - instance = _load_first_instance(copied_fixture) - - assert instance.value == "fresh" - assert not (copied_fixture / "nested" / "__pycache__").exists() - assert not (copied_fixture / "orphan.pyc").exists() - if (copied_fixture / "__pycache__" / f"main.{cache_tag}.pyc").exists(): - assert ( - copied_fixture / "__pycache__" / f"main.{cache_tag}.pyc" - ).read_bytes() != b"stale main bytecode" - - -def test_discover_plugins_allows_plugins_without_requirements_file( - tmp_path: Path, -) -> None: - plugins_dir = tmp_path / "plugins" - plugin_dir = plugins_dir / "no_requirements" - _write_plugin( - plugin_dir, - plugin_name="no_requirements", - class_name="NoRequirementsPlugin", - main_source=""" -from astrbot_sdk import Star - - -class NoRequirementsPlugin(Star): - value = "no-deps" -""", - write_requirements=False, - ) - - discovered = discover_plugins(plugins_dir) - - assert [plugin.name for plugin in discovered.plugins] == ["no_requirements"] - assert discovered.skipped_plugins == {} - assert discovered.issues == [] - - with _preserve_import_state("main"): - instance = _load_first_instance(plugin_dir) - - assert instance.value == "no-deps" diff --git a/astrbot-sdk/tests/test_runtime_peer.py b/astrbot-sdk/tests/test_runtime_peer.py deleted file mode 100644 index bcdfa50a7..000000000 --- a/astrbot-sdk/tests/test_runtime_peer.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Any - -import pytest - -from astrbot_sdk.errors import AstrBotError, ErrorCodes -from astrbot_sdk.protocol.messages import ( - EventMessage, - InvokeMessage, - PeerInfo, - ResultMessage, - parse_message, -) -from astrbot_sdk.runtime.peer import Peer -from astrbot_sdk.runtime.transport import Transport - - -class _ControlledTransport(Transport): - def __init__(self) -> None: - super().__init__() - self.sent_payloads: list[str] = [] - self.on_send: Callable[[str], Awaitable[None]] | None = None - - async def start(self) -> None: - self._closed.clear() - - async def stop(self) -> None: - self._closed.set() - - async def send(self, payload: str) -> None: - self.sent_payloads.append(payload) - if self.on_send is not None: - await self.on_send(payload) - - async def push_message(self, message: Any) -> None: - if isinstance(message, str): - payload = message - else: - payload = message.model_dump_json(exclude_none=True) - await self._dispatch(payload) - - def close_unexpected(self) -> None: - self._closed.set() - - -class _FailingSendTransport(_ControlledTransport): - async def send(self, payload: str) -> None: - self.sent_payloads.append(payload) - raise RuntimeError("send failed") - - -def _make_peer(transport: _ControlledTransport, *, name: str = "test-plugin") -> Peer: - return Peer( - transport=transport, - peer_info=PeerInfo(name=name, role="plugin", version="v4"), - ) - - -async def _stop_peer(peer: Peer) -> None: - await peer.stop() - if peer._transport_watch_task is not None: - await peer._transport_watch_task - - -@pytest.mark.asyncio -async def test_initialize_marks_remote_initialized_on_active_side() -> None: - transport = _ControlledTransport() - peer = _make_peer(transport) - - async def respond_to_initialize(payload: str) -> None: - message = parse_message(payload) - assert message.type == "initialize" - await transport.push_message( - ResultMessage( - id=message.id, - kind="initialize_result", - success=True, - output={ - "peer": { - "name": "astrbot-core", - "role": "core", - "version": "v4", - }, - "protocol_version": "1.0", - "capabilities": [], - "metadata": {"mode": "test"}, - }, - ) - ) - - transport.on_send = respond_to_initialize - await peer.start() - try: - waiter = asyncio.create_task(peer.wait_until_remote_initialized(timeout=0.2)) - await asyncio.sleep(0) - assert not waiter.done() - - output = await peer.initialize([]) - await waiter - - assert output.peer.name == "astrbot-core" - assert peer.remote_peer is not None - assert peer.remote_peer.name == "astrbot-core" - assert peer.remote_metadata["mode"] == "test" - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_wait_until_remote_initialized_fails_when_transport_closes_pre_init() -> ( - None -): - transport = _ControlledTransport() - peer = _make_peer(transport) - await peer.start() - try: - waiter = asyncio.create_task(peer.wait_until_remote_initialized(timeout=None)) - await asyncio.sleep(0) - - transport.close_unexpected() - - with pytest.raises(AstrBotError, match="连接在初始化完成前关闭") as exc_info: - await asyncio.wait_for(waiter, timeout=0.2) - - assert exc_info.value.code == ErrorCodes.PROTOCOL_ERROR - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_invoke_fails_pending_call_on_unexpected_transport_close() -> None: - transport = _ControlledTransport() - peer = _make_peer(transport) - await peer.start() - try: - invoke_task = asyncio.create_task(peer.invoke("llm.chat", {"prompt": "hello"})) - await asyncio.sleep(0) - - assert len(transport.sent_payloads) == 1 - transport.close_unexpected() - - with pytest.raises(AstrBotError, match="连接已关闭") as exc_info: - await asyncio.wait_for(invoke_task, timeout=0.2) - - assert exc_info.value.code == ErrorCodes.NETWORK_ERROR - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_invoke_stream_fails_pending_iterator_on_unexpected_transport_close() -> ( - None -): - transport = _ControlledTransport() - peer = _make_peer(transport) - await peer.start() - try: - iterator = await peer.invoke_stream("llm.stream", {"prompt": "hello"}) - consume_task = asyncio.create_task(anext(iterator)) - await asyncio.sleep(0) - - assert len(transport.sent_payloads) == 1 - transport.close_unexpected() - - with pytest.raises(AstrBotError, match="连接已关闭") as exc_info: - await asyncio.wait_for(consume_task, timeout=0.2) - - assert exc_info.value.code == ErrorCodes.NETWORK_ERROR - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_invoke_stream_hides_completed_event_by_default() -> None: - transport = _ControlledTransport() - peer = _make_peer(transport) - - async def emit_stream(payload: str) -> None: - message = parse_message(payload) - assert message.type == "invoke" - await transport.push_message(EventMessage(id=message.id, phase="started")) - await transport.push_message( - EventMessage(id=message.id, phase="delta", data={"text": "hello"}) - ) - await transport.push_message( - EventMessage(id=message.id, phase="completed", output={"text": "hello"}) - ) - - transport.on_send = emit_stream - await peer.start() - try: - iterator = await peer.invoke_stream("llm.stream", {"prompt": "hello"}) - events = [event async for event in iterator] - - assert [(event.phase, event.data, event.output) for event in events] == [ - ("delta", {"text": "hello"}, {}) - ] - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_invoke_stream_can_include_completed_event() -> None: - transport = _ControlledTransport() - peer = _make_peer(transport) - - async def emit_stream(payload: str) -> None: - message = parse_message(payload) - assert message.type == "invoke" - await transport.push_message(EventMessage(id=message.id, phase="started")) - await transport.push_message( - EventMessage(id=message.id, phase="delta", data={"text": "hello"}) - ) - await transport.push_message( - EventMessage(id=message.id, phase="completed", output={"text": "hello"}) - ) - - transport.on_send = emit_stream - await peer.start() - try: - iterator = await peer.invoke_stream( - "llm.stream", - {"prompt": "hello"}, - include_completed=True, - ) - events = [event async for event in iterator] - - assert [(event.phase, event.data, event.output) for event in events] == [ - ("delta", {"text": "hello"}, {}), - ("completed", {}, {"text": "hello"}), - ] - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_invoke_stream_failed_event_becomes_exception() -> None: - transport = _ControlledTransport() - peer = _make_peer(transport) - - async def emit_failed_event(payload: str) -> None: - message = parse_message(payload) - assert message.type == "invoke" - await transport.push_message(EventMessage(id=message.id, phase="started")) - await transport.push_message( - EventMessage( - id=message.id, - phase="failed", - error={ - "code": ErrorCodes.INTERNAL_ERROR, - "message": "boom", - "hint": "", - "retryable": False, - "docs_url": "", - }, - ) - ) - - transport.on_send = emit_failed_event - await peer.start() - try: - iterator = await peer.invoke_stream("llm.stream", {"prompt": "hello"}) - - with pytest.raises(AstrBotError, match="boom") as exc_info: - async for _event in iterator: - pass - - assert exc_info.value.code == ErrorCodes.INTERNAL_ERROR - finally: - await _stop_peer(peer) - - -@pytest.mark.asyncio -async def test_inbound_invoke_send_failure_marks_peer_unusable() -> None: - transport = _FailingSendTransport() - peer = _make_peer(transport) - - async def handle_invoke(_message: Any, _token: Any) -> dict[str, Any]: - return {"ok": True} - - peer.set_invoke_handler(handle_invoke) - await peer.start() - try: - await transport.push_message( - InvokeMessage( - id="msg_0001", - capability="demo.echo", - input={}, - stream=False, - ) - ) - - await asyncio.wait_for(peer.wait_closed(), timeout=0.2) - - assert peer._unusable is True - assert len(transport.sent_payloads) == 2 - finally: - await _stop_peer(peer) diff --git a/astrbot-sdk/tests/test_runtime_supervisor_registry_sync.py b/astrbot-sdk/tests/test_runtime_supervisor_registry_sync.py deleted file mode 100644 index af8f4bb17..000000000 --- a/astrbot-sdk/tests/test_runtime_supervisor_registry_sync.py +++ /dev/null @@ -1,298 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from astrbot_sdk.errors import AstrBotError, ErrorCodes -from astrbot_sdk.runtime.capability_router import CapabilityRouter -import astrbot_sdk.runtime.supervisor as supervisor_module -from astrbot_sdk.runtime.environment_groups import EnvironmentPlanResult -from astrbot_sdk.runtime.loader import PluginDiscoveryResult, PluginSpec -from astrbot_sdk.runtime.supervisor import SupervisorRuntime, WorkerSession -from astrbot_sdk.runtime.transport import Transport - - -class _DummyTransport(Transport): - async def start(self) -> None: - self._closed.clear() - - async def stop(self) -> None: - self._closed.set() - - async def send(self, payload: str) -> None: - return None - - -class _RecordingPeer: - def __init__(self) -> None: - self.initialize_calls: list[dict[str, object]] = [] - self.started = False - self.stopped = False - - def set_invoke_handler(self, _handler) -> None: - return None - - def set_cancel_handler(self, _handler) -> None: - return None - - async def start(self) -> None: - self.started = True - - async def initialize( - self, - handlers, - *, - provided_capabilities, - metadata, - ) -> None: - self.initialize_calls.append( - { - "handlers": list(handlers), - "provided_capabilities": list(provided_capabilities), - "metadata": dict(metadata), - } - ) - - async def stop(self) -> None: - self.stopped = True - - -class _StaticEnvManager: - def __init__(self, plugins: list[PluginSpec]) -> None: - self._plugins = list(plugins) - - def plan(self, _plugins: list[PluginSpec]) -> EnvironmentPlanResult: - return EnvironmentPlanResult(plugins=list(self._plugins)) - - -def _write_plugin_spec(tmp_path: Path, plugin_name: str) -> PluginSpec: - plugin_dir = tmp_path / plugin_name - plugin_dir.mkdir(parents=True, exist_ok=True) - manifest_path = plugin_dir / "plugin.yaml" - manifest_path.write_text( - f""" -_schema_version: 2 -name: {plugin_name} -author: tests -version: 1.0.0 -desc: supervisor registry sync tests - -runtime: - python: "3.12" - -components: - - class: main:TestPlugin -""".strip() - + "\n", - encoding="utf-8", - ) - requirements_path = plugin_dir / "requirements.txt" - requirements_path.write_text("", encoding="utf-8") - (plugin_dir / "main.py").write_text( - "from astrbot_sdk import Star\n\n\nclass TestPlugin(Star):\n pass\n", - encoding="utf-8", - ) - return PluginSpec( - name=plugin_name, - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version="3.12", - manifest_data={ - "name": plugin_name, - "author": "tests", - "version": "1.0.0", - "desc": "supervisor registry sync tests", - "components": [{"class": "main:TestPlugin"}], - "runtime": {"python": "3.12"}, - }, - ) - - -@pytest.mark.asyncio -async def test_supervisor_publishes_plugin_registry_in_two_phases( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - alpha = _write_plugin_spec(tmp_path, "alpha") - beta = _write_plugin_spec(tmp_path, "beta") - plugins = [alpha, beta] - runtime = SupervisorRuntime( - transport=_DummyTransport(), - plugins_dir=tmp_path, - env_manager=_StaticEnvManager(plugins), - ) - peer = _RecordingPeer() - runtime.peer = peer # type: ignore[assignment] - - monkeypatch.setattr( - supervisor_module, - "discover_plugins", - lambda _plugins_dir: PluginDiscoveryResult( - plugins=list(plugins), - skipped_plugins={}, - issues=[], - ), - ) - - phase_snapshots: list[tuple[str, dict[str, bool]]] = [] - - class _FakeWorkerSession: - def __init__( - self, - *, - plugin=None, - group=None, - repo_root, - env_manager, - capability_router, - on_closed=None, - ) -> None: - del group, repo_root, env_manager, capability_router, on_closed - assert plugin is not None - self.plugin = plugin - self.plugins = [plugin] - self.group_id = plugin.name - self.handlers = [] - self.provided_capabilities = [] - self.loaded_plugins: list[str] = [] - self.skipped_plugins: dict[str, str] = {} - self.issues = [] - self.capability_sources: dict[str, str] = {} - - async def start(self) -> None: - phase_snapshots.append( - ( - self.plugin.name, - { - name: bool(entry.metadata.get("enabled", False)) - for name, entry in runtime.capability_router._plugins.items() - }, - ) - ) - if self.plugin.name == "beta": - raise RuntimeError("beta worker failed") - self.loaded_plugins = [self.plugin.name] - - async def stop(self) -> None: - return None - - def start_close_watch(self) -> None: - return None - - def describe(self) -> dict[str, object]: - return { - "group_id": self.group_id, - "plugins": [plugin.name for plugin in self.plugins], - "loaded_plugins": list(self.loaded_plugins), - "skipped_plugins": dict(self.skipped_plugins), - "issues": list(self.issues), - } - - monkeypatch.setattr(supervisor_module, "WorkerSession", _FakeWorkerSession) - - await runtime.start() - - assert phase_snapshots == [ - ("alpha", {"alpha": False, "beta": False}), - ("beta", {"alpha": False, "beta": False}), - ] - assert runtime.loaded_plugins == ["alpha"] - assert runtime.skipped_plugins["beta"] == "beta worker failed" - assert runtime.capability_router._plugins["alpha"].metadata["enabled"] is True - assert runtime.capability_router._plugins["beta"].metadata["enabled"] is False - assert peer.started is True - assert len(peer.initialize_calls) == 1 - assert peer.initialize_calls[0]["metadata"] == { - "plugins": ["alpha"], - "skipped_plugins": {"beta": "beta worker failed"}, - "issues": [ - { - "severity": "error", - "phase": "load", - "plugin_id": "beta", - "message": "插件 worker 启动失败", - "details": "beta worker failed", - "hint": "", - } - ], - "aggregated_handler_ids": [], - "worker_groups": [ - { - "group_id": "alpha", - "plugins": ["alpha"], - "loaded_plugins": ["alpha"], - "skipped_plugins": {}, - "issues": [], - } - ], - "worker_group_count": 1, - } - - -@pytest.mark.asyncio -async def test_worker_session_start_surfaces_init_waiter_failure( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - plugin = _write_plugin_spec(tmp_path, "alpha") - session = WorkerSession( - plugin=plugin, - repo_root=tmp_path, - env_manager=_StaticEnvManager([plugin]), - capability_router=CapabilityRouter(), - ) - session._worker_command = lambda: ( - Path("/usr/bin/python3"), - ["/usr/bin/python3", "-m", "astrbot_sdk", "worker"], - str(tmp_path), - ) - - class _StubStdioTransport: - def __init__(self, *, command, cwd, env) -> None: - self.command = command - self.cwd = cwd - self.env = env - - created_peers: list[object] = [] - - class _FailingInitPeer: - def __init__(self, *, transport, peer_info) -> None: - del transport, peer_info - self.remote_handlers = [] - self.remote_provided_capabilities = [] - self.remote_metadata = {} - self.stopped = False - created_peers.append(self) - - def set_initialize_handler(self, _handler) -> None: - return None - - def set_invoke_handler(self, _handler) -> None: - return None - - async def start(self) -> None: - return None - - async def wait_until_remote_initialized( - self, timeout: float | None = None - ) -> None: - del timeout - raise AstrBotError.protocol_error("连接在初始化完成前关闭") - - async def wait_closed(self) -> None: - return None - - async def stop(self) -> None: - self.stopped = True - - monkeypatch.setattr(supervisor_module, "StdioTransport", _StubStdioTransport) - monkeypatch.setattr(supervisor_module, "Peer", _FailingInitPeer) - - with pytest.raises(AstrBotError, match="连接在初始化完成前关闭") as exc_info: - await session.start() - - assert exc_info.value.code == ErrorCodes.PROTOCOL_ERROR - assert len(created_peers) == 1 - assert created_peers[0].stopped is True diff --git a/astrbot-sdk/tests/test_sdk_environment_groups.py b/astrbot-sdk/tests/test_sdk_environment_groups.py deleted file mode 100644 index 91095de16..000000000 --- a/astrbot-sdk/tests/test_sdk_environment_groups.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from astrbot_sdk.runtime.environment_groups import GroupEnvironmentManager - - -@pytest.mark.unit -def test_matches_python_version_accepts_uv_version_info_format(tmp_path: Path) -> None: - venv_path = tmp_path / "venv" - venv_path.mkdir() - (venv_path / "pyvenv.cfg").write_text( - "\n".join( - [ - "home = C:\\Users\\tester\\AppData\\Local\\Programs\\Python\\Python313", - "implementation = CPython", - "uv = 0.9.17", - "version_info = 3.13.12", - "include-system-site-packages = true", - ] - ), - encoding="utf-8", - ) - - assert GroupEnvironmentManager._matches_python_version(venv_path, "3.13") is True - assert GroupEnvironmentManager._matches_python_version(venv_path, "3.11") is False diff --git a/astrbot-sdk/tests/test_sdk_peer_errors.py b/astrbot-sdk/tests/test_sdk_peer_errors.py deleted file mode 100644 index 20845b2fc..000000000 --- a/astrbot-sdk/tests/test_sdk_peer_errors.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import pytest -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.protocol.messages import ErrorPayload, ResultMessage - -pytestmark = pytest.mark.unit - - -def test_error_payload_accepts_docs_url_and_details() -> None: - payload = ErrorPayload.model_validate( - AstrBotError.invalid_input( - "bad input", - docs_url="https://docs.astrbot.org/sdk/errors#invalid-input", - details={"field": "name"}, - ).to_payload() - ) - - assert payload.docs_url == "https://docs.astrbot.org/sdk/errors#invalid-input" - assert payload.details == {"field": "name"} - - -def test_failed_result_round_trip_preserves_error_metadata() -> None: - error = AstrBotError.internal_error( - "boom", - hint="try again later", - docs_url="https://docs.astrbot.org/sdk/errors#internal-error", - details={"phase": "invoke"}, - ) - message = ResultMessage( - id="req-1", - success=False, - error=ErrorPayload.model_validate(error.to_payload()), - ) - - restored = AstrBotError.from_payload( - message.error.model_dump() if message.error else {} - ) - - assert restored.code == error.code - assert restored.message == error.message - assert restored.hint == error.hint - assert restored.docs_url == error.docs_url - assert restored.details == error.details diff --git a/astrbot-sdk/tests/test_sdk_transport.py b/astrbot-sdk/tests/test_sdk_transport.py deleted file mode 100644 index 76cd2a79d..000000000 --- a/astrbot-sdk/tests/test_sdk_transport.py +++ /dev/null @@ -1,44 +0,0 @@ -# ruff: noqa: SLF001 -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -from astrbot_sdk.runtime import transport as transport_module -from astrbot_sdk.runtime.transport import StdioTransport - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_stdio_transport_retries_transient_windows_access_denied( - monkeypatch: pytest.MonkeyPatch, -) -> None: - attempts = 0 - fake_process = SimpleNamespace() - - async def fake_create_subprocess_exec(*args, **kwargs): - nonlocal attempts - attempts += 1 - if attempts == 1: - error = PermissionError(13, "Access is denied") - error.winerror = 5 - raise error - return fake_process - - async def fake_sleep(_delay: float) -> None: - return None - - monkeypatch.setattr( - transport_module.asyncio, - "create_subprocess_exec", - fake_create_subprocess_exec, - ) - monkeypatch.setattr(transport_module.asyncio, "sleep", fake_sleep) - monkeypatch.setattr(transport_module.sys, "platform", "win32") - - transport = StdioTransport(command=["python", "--version"]) - - process = await transport._start_subprocess_with_retry() - - assert process is fake_process - assert attempts == 2 diff --git a/astrbot-sdk/tests/test_session_waiter_usage.py b/astrbot-sdk/tests/test_session_waiter_usage.py deleted file mode 100644 index bcbe6cfdf..000000000 --- a/astrbot-sdk/tests/test_session_waiter_usage.py +++ /dev/null @@ -1,241 +0,0 @@ -from __future__ import annotations - -import asyncio -import importlib - -import pytest - -from astrbot_sdk._internal.testing_support import ( - MockCapabilityRouter, - MockContext, - MockMessageEvent, - MockPeer, -) -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.messages import InvokeMessage -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.session_waiter import ( - SessionController, - SessionWaiterManager, - _mark_session_waiter_handler_task, - _unmark_session_waiter_handler_task, - session_waiter, -) - -session_waiter_module = importlib.import_module("astrbot_sdk.session_waiter") - - -def _attach_waiter_manager(ctx: MockContext) -> SessionWaiterManager: - manager = SessionWaiterManager(plugin_id=ctx.plugin_id, peer=ctx.peer) - setattr(ctx.peer, "_session_waiter_manager", manager) - return manager - - -@pytest.mark.asyncio -async def test_session_waiter_register_task_pattern_is_non_blocking( - monkeypatch: pytest.MonkeyPatch, -) -> None: - ctx = MockContext() - manager = _attach_waiter_manager(ctx) - warnings: list[tuple[object, ...]] = [] - received: list[str] = [] - - monkeypatch.setattr( - session_waiter_module.logger, - "warning", - lambda *args: warnings.append(args), - ) - - @session_waiter(timeout=30) - async def waiter( - controller: SessionController, - event: MessageEvent, - ) -> None: - received.append(event.text) - controller.stop() - - initial = MockMessageEvent(text="/bind", session_id="session-1", context=ctx) - progress = ["before"] - with caller_plugin_scope(ctx.plugin_id): - background_task = await ctx.register_task(waiter(initial), "waiter:collect") - progress.append("after") - - assert progress == ["before", "after"] - assert not background_task.done() - - for _ in range(5): - if manager.has_waiter(initial): - break - await asyncio.sleep(0) - - assert manager.has_waiter(initial) - - followup = MockMessageEvent(text="alice", session_id="session-1", context=ctx) - await manager.dispatch(followup) - await background_task - - assert received == ["alice"] - assert not manager.has_waiter(initial) - assert warnings == [] - - -@pytest.mark.asyncio -async def test_session_waiter_warns_on_direct_await_in_handler_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - ctx = MockContext() - manager = _attach_waiter_manager(ctx) - warnings: list[tuple[object, ...]] = [] - received: list[str] = [] - - monkeypatch.setattr( - session_waiter_module.logger, - "warning", - lambda *args: warnings.append(args), - ) - - @session_waiter(timeout=30) - async def waiter( - controller: SessionController, - event: MessageEvent, - ) -> None: - received.append(event.text) - controller.stop() - - initial = MockMessageEvent(text="/bind", session_id="session-2", context=ctx) - - async def direct_wait() -> None: - current_task = asyncio.current_task() - assert current_task is not None - _mark_session_waiter_handler_task(current_task) - try: - await waiter(initial) - finally: - _unmark_session_waiter_handler_task(current_task) - - with caller_plugin_scope(ctx.plugin_id): - wait_task = asyncio.create_task(direct_wait()) - - for _ in range(5): - if manager.has_waiter(initial): - break - await asyncio.sleep(0) - - assert manager.has_waiter(initial) - - followup = MockMessageEvent(text="bob", session_id="session-2", context=ctx) - await manager.dispatch(followup) - await wait_task - - assert received == ["bob"] - assert warnings == [ - ( - "Direct await on session_waiter blocks the current handler dispatch; " - 'prefer `await ctx.register_task(waiter(...), "...")`: ' - "plugin_id={} session_key={}", - "test-plugin", - "session-2", - ) - ] - - -@pytest.mark.asyncio -async def test_session_waiter_warns_on_direct_await_in_redispatched_waiter_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="test-plugin", peer=peer, handlers=[]) - ctx = Context(peer=peer, plugin_id="test-plugin") - warnings: list[tuple[object, ...]] = [] - received: list[tuple[str, str]] = [] - - monkeypatch.setattr( - session_waiter_module.logger, - "warning", - lambda *args: warnings.append(args), - ) - - @session_waiter(timeout=30) - async def second_wait( - controller: SessionController, - event: MessageEvent, - ) -> None: - received.append(("second", event.text)) - controller.stop() - - @session_waiter(timeout=30) - async def first_wait( - controller: SessionController, - event: MessageEvent, - ) -> None: - received.append(("first", event.text)) - await second_wait(event) - controller.stop() - - initial_event = MessageEvent.from_payload( - { - "type": "message", - "event_type": "message", - "text": "/bind", - "session_id": "session-redispatch", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - }, - context=ctx, - ) - with caller_plugin_scope(ctx.plugin_id): - waiter_task = await ctx.register_task(first_wait(initial_event), "waiter:first") - - for _ in range(10): - if dispatcher.has_active_waiter(initial_event): - break - await asyncio.sleep(0) - - redispatch_task = asyncio.create_task( - dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-1", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": { - **initial_event.to_payload(), - "text": "alice", - }, - "args": {}, - }, - ), - CancelToken(), - ) - ) - - for _ in range(10): - if any( - args and str(args[0]).startswith("Direct await on session_waiter") - for args in warnings - ): - break - await asyncio.sleep(0) - - assert redispatch_task.done() is False - redispatch_task.cancel() - waiter_task.cancel() - await asyncio.gather(redispatch_task, waiter_task, return_exceptions=True) - - assert received == [("first", "alice")] - assert any( - args - == ( - "Direct await on session_waiter blocks the current handler dispatch; " - 'prefer `await ctx.register_task(waiter(...), "...")`: ' - "plugin_id={} session_key={}", - "test-plugin", - "session-redispatch", - ) - for args in warnings - ) diff --git a/astrbot-sdk/tests/test_star_on_error_fallback.py b/astrbot-sdk/tests/test_star_on_error_fallback.py deleted file mode 100644 index 987fb503e..000000000 --- a/astrbot-sdk/tests/test_star_on_error_fallback.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.star import Star - - -class _DummyEvent: - def __init__(self) -> None: - self.replies: list[str] = [] - - async def reply(self, message: str) -> None: - self.replies.append(message) - - -@pytest.mark.asyncio -async def test_handle_error_fallback_does_not_instantiate_star( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def _fake_default_on_error(error: Exception, event, ctx) -> None: - del ctx - await event.reply(str(error)) - - def _fail_init(self) -> None: - raise AssertionError("Star should not be instantiated for fallback on_error") - - monkeypatch.setattr(Star, "default_on_error", staticmethod(_fake_default_on_error)) - monkeypatch.setattr(Star, "__init__", _fail_init) - - dispatcher = HandlerDispatcher( - plugin_id="plugin", peer=SimpleNamespace(), handlers=[] - ) - event = _DummyEvent() - - await dispatcher._handle_error( - object(), - RuntimeError("boom"), - event, - SimpleNamespace(), - ) - - assert event.replies == ["boom"] - - -@pytest.mark.asyncio -async def test_default_on_error_formats_astrbot_error_reply() -> None: - event = _DummyEvent() - error = AstrBotError.invalid_input( - "bad payload", - hint="check payload", - docs_url="https://example.com/docs", - details={"b": 2, "a": 1}, - ) - - await Star.default_on_error(error, event, SimpleNamespace()) - - assert len(event.replies) == 1 - assert "check payload" in event.replies[0] - assert "https://example.com/docs" in event.replies[0] - assert '"a": 1' in event.replies[0] - assert '"b": 2' in event.replies[0] - - -@pytest.mark.asyncio -async def test_default_on_error_replies_generic_message_for_unknown_errors() -> None: - event = _DummyEvent() - - await Star.default_on_error(RuntimeError("boom"), event, SimpleNamespace()) - - assert len(event.replies) == 1 - assert event.replies[0] - - -@pytest.mark.asyncio -async def test_on_error_does_not_dispatch_via_subclass_default_on_error() -> None: - class PluginWithShadowedDefault(Star): - async def default_on_error(self, error: Exception, event, ctx) -> None: - del error, event, ctx - raise AssertionError( - "Star.on_error should not virtual-dispatch default_on_error" - ) - - expected_event = _DummyEvent() - actual_event = _DummyEvent() - - await Star.default_on_error(RuntimeError("boom"), expected_event, SimpleNamespace()) - await PluginWithShadowedDefault().on_error( - RuntimeError("boom"), - actual_event, - SimpleNamespace(), - ) - - assert actual_event.replies == expected_event.replies diff --git a/astrbot-sdk/tests/test_testing_session_waiter.py b/astrbot-sdk/tests/test_testing_session_waiter.py deleted file mode 100644 index 49ebc80d0..000000000 --- a/astrbot-sdk/tests/test_testing_session_waiter.py +++ /dev/null @@ -1,825 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.messages import InvokeMessage -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.session_waiter import SessionController -from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness -from astrbot_sdk._internal.testing_support import MockCapabilityRouter, MockPeer - - -def _write_session_waiter_plugin(plugin_dir: Path) -> None: - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - "\n".join( - [ - "name: session_waiter_plugin", - "display_name: Session Waiter Plugin", - "desc: test plugin", - "author: tests", - "version: 0.1.0", - "runtime:", - ' python: "3.11"', - "components:", - " - class: main:SessionWaiterPlugin", - "", - ] - ), - encoding="utf-8", - ) - (plugin_dir / "main.py").write_text( - "\n".join( - [ - "from astrbot_sdk import Context, MessageEvent, SessionController, Star, on_command, session_waiter", - "", - "", - "class SessionWaiterPlugin(Star):", - ' @on_command("start")', - " async def start(self, event: MessageEvent, ctx: Context) -> None:", - ' await event.reply("ready")', - ' await ctx.register_task(self.wait_for_followup(event), "wait for followup")', - "", - " @session_waiter(timeout=30)", - " async def wait_for_followup(", - " self,", - " controller: SessionController,", - " event: MessageEvent,", - " ) -> None:", - " del controller", - ' await event.reply(f"followup:{event.text}")', - "", - ] - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - - -def _build_event(*, text: str, session_id: str, peer: MockPeer) -> MessageEvent: - return MessageEvent.from_payload( - { - "type": "message", - "event_type": "message", - "text": text, - "session_id": session_id, - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - }, - context=Context(peer=peer, plugin_id="test-plugin"), - ) - - -def test_plugin_harness_waiter_probe_uses_dispatcher_public_api(tmp_path: Path) -> None: - plugin_dir = tmp_path / "session_waiter_plugin" - _write_session_waiter_plugin(plugin_dir) - harness = PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) - peer = MockPeer(MockCapabilityRouter()) - probe_event = _build_event(text="hello", session_id="session-1", peer=peer) - harness.lifecycle_context = probe_event._context - - calls: list[MessageEvent] = [] - - def has_active_waiter(event: MessageEvent) -> bool: - calls.append(event) - return True - - harness.dispatcher = SimpleNamespace(has_active_waiter=has_active_waiter) - - assert harness._has_waiter_for_event(probe_event.to_payload()) is True - assert len(calls) == 1 - assert calls[0].unified_msg_origin == "session-1" - - -@pytest.mark.asyncio -async def test_plugin_harness_dispatches_followup_to_session_waiter( - tmp_path: Path, -) -> None: - plugin_dir = tmp_path / "session_waiter_plugin" - _write_session_waiter_plugin(plugin_dir) - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - first_records = await harness.dispatch_text("start", session_id="session-1") - await asyncio.sleep(0) - second_records = await harness.dispatch_text("next", session_id="session-1") - - assert [record.text for record in first_records] == ["ready"] - assert [record.text for record in second_records] == ["followup:next"] - - -@pytest.mark.asyncio -async def test_handler_dispatcher_exposes_active_waiter_probe() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="test-plugin", peer=peer, handlers=[]) - event = _build_event(text="hello", session_id="session-1", peer=peer) - - assert dispatcher.has_active_waiter(event) is False - - async def waiter_task() -> None: - with caller_plugin_scope("test-plugin"): - await dispatcher._session_waiters.register( - event=event, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - assert dispatcher.has_active_waiter(event) is True - - await dispatcher._session_waiters.fail( - event.unified_msg_origin, RuntimeError("stop waiter") - ) - with pytest.raises(RuntimeError, match="stop waiter"): - await task - assert dispatcher.has_active_waiter(event) is False - - -@pytest.mark.asyncio -async def test_session_waiter_fail_defaults_to_current_caller_plugin_scope() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="worker-group", peer=peer, handlers=[]) - event_alpha = _build_event(text="hello", session_id="session-1", peer=peer) - event_alpha._context = Context(peer=peer, plugin_id="plugin.alpha") - event_beta = _build_event(text="hello", session_id="session-1", peer=peer) - event_beta._context = Context(peer=peer, plugin_id="plugin.beta") - - async def waiter_alpha() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=event_alpha, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - async def waiter_beta() -> None: - with caller_plugin_scope("plugin.beta"): - await dispatcher._session_waiters.register( - event=event_beta, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - task_alpha = asyncio.create_task(waiter_alpha()) - task_beta = asyncio.create_task(waiter_beta()) - await asyncio.sleep(0) - - with caller_plugin_scope("plugin.alpha"): - assert ( - await dispatcher._session_waiters.fail( - "session-1", - RuntimeError("stop alpha"), - ) - is True - ) - - with pytest.raises(RuntimeError, match="stop alpha"): - await task_alpha - assert dispatcher.has_active_waiter(event_beta) is True - - await dispatcher._session_waiters.fail( - "session-1", - RuntimeError("stop beta"), - plugin_id="plugin.beta", - ) - with pytest.raises(RuntimeError, match="stop beta"): - await task_beta - - -@pytest.mark.asyncio -async def test_session_waiter_dispatch_preserves_source_event_payload() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="test-plugin", peer=peer, handlers=[]) - event_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "target": {"conversation_id": "session-1", "platform": "test"}, - "raw": {"event_type": "message"}, - } - event = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="test-plugin"), - ) - seen_payloads: list[dict[str, object]] = [] - - async def capture_waiter( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - source_payload = waiter_event._context._source_event_payload - seen_payloads.append(dict(source_payload)) - controller.stop() - - async def waiter_task() -> None: - with caller_plugin_scope("test-plugin"): - await dispatcher._session_waiters.register( - event=event, - handler=capture_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": dict(event_payload), - "args": {}, - }, - ), - CancelToken(), - ) - await task - - assert seen_payloads == [event_payload] - - -@pytest.mark.asyncio -async def test_session_waiter_dispatch_serializes_followups_per_waiter() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="test-plugin", peer=peer, handlers=[]) - event = _build_event(text="hello", session_id="session-serial", peer=peer) - handler_entered = asyncio.Event() - release_handler = asyncio.Event() - invocations: list[str] = [] - - async def slow_waiter( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - invocations.append(waiter_event.text) - handler_entered.set() - await release_handler.wait() - controller.stop() - - async def waiter_task() -> None: - with caller_plugin_scope("test-plugin"): - await dispatcher._session_waiters.register( - event=event, - handler=slow_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - first_followup = _build_event(text="first", session_id="session-serial", peer=peer) - second_followup = _build_event( - text="second", - session_id="session-serial", - peer=peer, - ) - - first_dispatch = asyncio.create_task( - dispatcher._session_waiters.dispatch(first_followup) - ) - await handler_entered.wait() - - second_dispatch = asyncio.create_task( - dispatcher._session_waiters.dispatch(second_followup) - ) - await asyncio.sleep(0) - - assert invocations == ["first"] - assert second_dispatch.done() is False - - release_handler.set() - - await first_dispatch - await second_dispatch - await task - - assert invocations == ["first"] - - -@pytest.mark.asyncio -async def test_has_active_waiter_ignores_completed_waiter_before_unregister() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher(plugin_id="test-plugin", peer=peer, handlers=[]) - event = _build_event(text="hello", session_id="session-1", peer=peer) - release_unregister = asyncio.Event() - manager = dispatcher._session_waiters - original_unregister = manager.unregister - - async def delayed_unregister( - session_key: str, - *, - plugin_id: str | None = None, - ) -> None: - await release_unregister.wait() - await original_unregister(session_key, plugin_id=plugin_id) - - manager.unregister = delayed_unregister # type: ignore[method-assign] - - async def waiter_task() -> None: - with caller_plugin_scope("test-plugin"): - await manager.register( - event=event, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - assert dispatcher.has_active_waiter(event) is True - - await manager.fail(event.unified_msg_origin, RuntimeError("stop waiter")) - await asyncio.sleep(0) - - assert dispatcher.has_active_waiter(event) is False - - release_unregister.set() - with pytest.raises(RuntimeError, match="stop waiter"): - await task - - -@pytest.mark.asyncio -async def test_session_waiter_dispatch_uses_registering_plugin_id() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher( - plugin_id="worker-group", - peer=peer, - handlers=[], - ) - event_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - } - register_event = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ) - seen_plugin_ids: list[str] = [] - - async def capture_waiter( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def waiter_task() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=register_event, - handler=capture_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-plugin-id", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": dict(event_payload), - "args": {}, - }, - ), - CancelToken(), - ) - await task - - assert seen_plugin_ids == ["plugin.alpha"] - - -@pytest.mark.asyncio -async def test_session_waiter_dispatch_resolves_session_from_target_payload() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher( - plugin_id="worker-group", - peer=peer, - handlers=[], - ) - register_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - } - target_only_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "user_id": "tester", - "platform_id": "test", - "message_type": "private", - "target": {"conversation_id": "session-1", "platform": "test"}, - "raw": {"event_type": "message"}, - } - register_event = MessageEvent.from_payload( - register_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ) - seen_plugin_ids: list[str] = [] - - async def capture_waiter( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def waiter_task() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=register_event, - handler=capture_waiter, - timeout=30, - record_history_chains=False, - ) - - task = asyncio.create_task(waiter_task()) - await asyncio.sleep(0) - - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-target-only", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": dict(target_only_payload), - "args": {}, - }, - ), - CancelToken(), - ) - await task - - assert seen_plugin_ids == ["plugin.alpha"] - - -@pytest.mark.asyncio -async def test_session_waiters_do_not_replace_across_plugins_same_session() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher( - plugin_id="worker-group", - peer=peer, - handlers=[], - ) - event_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - } - event_a = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ) - event_b = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.beta"), - ) - seen_plugin_ids: list[str] = [] - - async def waiter_alpha( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def waiter_beta( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def task_alpha() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=event_a, - handler=waiter_alpha, - timeout=30, - record_history_chains=False, - ) - - async def task_beta() -> None: - with caller_plugin_scope("plugin.beta"): - await dispatcher._session_waiters.register( - event=event_b, - handler=waiter_beta, - timeout=30, - record_history_chains=False, - ) - - waiter_task_alpha = asyncio.create_task(task_alpha()) - waiter_task_beta = asyncio.create_task(task_beta()) - await asyncio.sleep(0) - - assert sorted(dispatcher._session_waiters.get_waiter_plugin_ids("session-1")) == [ - "plugin.alpha", - "plugin.beta", - ] - - await dispatcher._session_waiters.dispatch( - MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ), - plugin_id="plugin.alpha", - ) - await dispatcher._session_waiters.dispatch( - MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.beta"), - ), - plugin_id="plugin.beta", - ) - await waiter_task_alpha - await waiter_task_beta - - assert sorted(seen_plugin_ids) == ["plugin.alpha", "plugin.beta"] - - -@pytest.mark.asyncio -async def test_session_waiter_dispatch_accepts_explicit_plugin_id_for_ambiguous_session() -> ( - None -): - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher( - plugin_id="worker-group", - peer=peer, - handlers=[], - ) - event_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - } - event_a = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ) - event_b = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.beta"), - ) - seen_plugin_ids: list[str] = [] - - async def waiter_alpha( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def waiter_beta( - controller: SessionController, - waiter_event: MessageEvent, - ) -> None: - seen_plugin_ids.append(waiter_event._context.plugin_id) - controller.stop() - - async def task_alpha() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=event_a, - handler=waiter_alpha, - timeout=30, - record_history_chains=False, - ) - - async def task_beta() -> None: - with caller_plugin_scope("plugin.beta"): - await dispatcher._session_waiters.register( - event=event_b, - handler=waiter_beta, - timeout=30, - record_history_chains=False, - ) - - waiter_task_alpha = asyncio.create_task(task_alpha()) - waiter_task_beta = asyncio.create_task(task_beta()) - await asyncio.sleep(0) - - with pytest.raises(LookupError, match="explicit plugin identity"): - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-ambiguous", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "event": dict(event_payload), - "args": {}, - }, - ), - CancelToken(), - ) - - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-explicit-alpha", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "plugin_id": "plugin.alpha", - "event": dict(event_payload), - "args": {}, - }, - ), - CancelToken(), - ) - await dispatcher.invoke( - InvokeMessage( - id="req-session-waiter-explicit-beta", - capability="handler.invoke", - input={ - "handler_id": "__sdk_session_waiter__", - "plugin_id": "plugin.beta", - "event": dict(event_payload), - "args": {}, - }, - ), - CancelToken(), - ) - - await waiter_task_alpha - await waiter_task_beta - - assert sorted(seen_plugin_ids) == ["plugin.alpha", "plugin.beta"] - - -@pytest.mark.asyncio -async def test_fail_without_plugin_id_does_not_broadcast_across_plugins() -> None: - peer = MockPeer(MockCapabilityRouter()) - dispatcher = HandlerDispatcher( - plugin_id="worker-group", - peer=peer, - handlers=[], - ) - event_payload = { - "type": "message", - "event_type": "message", - "text": "followup", - "session_id": "session-1", - "user_id": "tester", - "platform": "test", - "platform_id": "test", - "message_type": "private", - "raw": {"event_type": "message"}, - } - event_a = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.alpha"), - ) - event_b = MessageEvent.from_payload( - event_payload, - context=Context(peer=peer, plugin_id="plugin.beta"), - ) - - async def waiter_alpha() -> None: - with caller_plugin_scope("plugin.alpha"): - await dispatcher._session_waiters.register( - event=event_a, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - async def waiter_beta() -> None: - with caller_plugin_scope("plugin.beta"): - await dispatcher._session_waiters.register( - event=event_b, - handler=_noop_waiter, - timeout=30, - record_history_chains=False, - ) - - task_a = asyncio.create_task(waiter_alpha()) - task_b = asyncio.create_task(waiter_beta()) - await asyncio.sleep(0) - - assert ( - await dispatcher._session_waiters.fail( - "session-1", - RuntimeError("stop waiter"), - ) - is False - ) - assert dispatcher.has_active_waiter(event_a) is True - assert dispatcher.has_active_waiter(event_b) is True - - await dispatcher._session_waiters.fail( - "session-1", - RuntimeError("stop alpha"), - plugin_id="plugin.alpha", - ) - with pytest.raises(RuntimeError, match="stop alpha"): - await task_a - - assert dispatcher.has_active_waiter(event_b) is True - await dispatcher._session_waiters.fail( - "session-1", - RuntimeError("stop beta"), - plugin_id="plugin.beta", - ) - with pytest.raises(RuntimeError, match="stop beta"): - await task_b - - -@pytest.mark.asyncio -async def test_plugin_harness_waits_for_waiter_side_effects_after_stop( - tmp_path: Path, -) -> None: - plugin_dir = tmp_path / "session_waiter_stop_after_side_effects" - _write_session_waiter_plugin(plugin_dir) - (plugin_dir / "main.py").write_text( - "\n".join( - [ - "import asyncio", - "from astrbot_sdk import Context, MessageEvent, SessionController, Star, on_command, session_waiter", - "", - "", - "class SessionWaiterPlugin(Star):", - ' @on_command("start")', - " async def start(self, event: MessageEvent, ctx: Context) -> None:", - ' await event.reply("ready")', - ' await ctx.register_task(self.wait_for_followup(event), "wait for followup")', - "", - " @session_waiter(timeout=30)", - " async def wait_for_followup(", - " self,", - " controller: SessionController,", - " event: MessageEvent,", - " ) -> None:", - " controller.stop()", - " await asyncio.sleep(0)", - ' await event.reply(f"followup:{event.text}")', - "", - ] - ), - encoding="utf-8", - ) - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - first_records = await harness.dispatch_text("start", session_id="session-1") - second_records = await harness.dispatch_text("next", session_id="session-1") - - assert [record.text for record in first_records] == ["ready"] - assert [record.text for record in second_records] == ["followup:next"] - - -async def _noop_waiter( - controller: SessionController, - waiter_event: MessageEvent, -) -> None: - del waiter_event - controller.stop() diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index ae52cf5d4..a4f9d8081 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -1,60 +1,5 @@ -from __future__ import annotations - import os -from importlib import import_module -from typing import TYPE_CHECKING, Any -from .utils.astrbot_path import get_astrbot_data_path - -if TYPE_CHECKING: - from .config import AstrBotConfig - from .db.sqlite import SQLiteDatabase - from .file_token_service import FileTokenService - from .log import LogBroker, LogManager - from .utils.pip_installer import DependencyConflictError, PipInstaller - from .utils.requirements_utils import ( - RequirementsPrecheckFailed, - find_missing_requirements, - find_missing_requirements_or_raise, - ) -else: - AstrBotConfig: Any - SQLiteDatabase: Any - FileTokenService: Any - LogBroker: Any - LogManager: Any - DependencyConflictError: Any - PipInstaller: Any - RequirementsPrecheckFailed: Any - find_missing_requirements: Any - find_missing_requirements_or_raise: Any - astrbot_config: Any - db_helper: Any - file_token_service: Any - html_renderer: Any - logger: Any - pip_installer: Any - sp: Any - -os.makedirs(get_astrbot_data_path(), exist_ok=True) - -DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t") - -__all__ = [ - "AstrBotConfig", - "DEMO_MODE", - "DependencyConflictError", - "FileTokenService", - "LogBroker", - "LogManager", - "PipInstaller", - "RequirementsPrecheckFailed", - "SQLiteDatabase", - "astrbot_config", - "db_helper", - "file_token_service", - "find_missing_requirements", - "find_missing_requirements_or_raise", from astrbot.core.config import AstrBotConfig from astrbot.core.config.default import DB_PATH from astrbot.core.db.sqlite import SQLiteDatabase @@ -132,129 +77,5 @@ __all__ = [ "logger", "pip_installer", "sp", -] - -_SINGLETON_CACHE: dict[str, Any] = {} - - -def _get_astrbot_config(): - config_module = import_module(".config", __name__) - cached = _SINGLETON_CACHE.get("astrbot_config") - if cached is None: - cached = config_module.AstrBotConfig() - _SINGLETON_CACHE["astrbot_config"] = cached - return cached - - -def _get_log_manager(): - return import_module(".log", __name__).LogManager - - -def _get_logger(): - cached = _SINGLETON_CACHE.get("logger") - if cached is None: - logger_obj = _get_log_manager().GetLogger(log_name="astrbot") - config = _get_astrbot_config() - log_manager = _get_log_manager() - log_manager.configure_logger(logger_obj, config) - log_manager.configure_trace_logger(config) - _SINGLETON_CACHE["logger"] = logger_obj - cached = logger_obj - return cached - - -def _get_db_helper(): - cached = _SINGLETON_CACHE.get("db_helper") - if cached is None: - sqlite_module = import_module(".db.sqlite", __name__) - default_module = import_module(".config.default", __name__) - cached = sqlite_module.SQLiteDatabase(default_module.DB_PATH) - _SINGLETON_CACHE["db_helper"] = cached - return cached - - -def _get_shared_preferences(): - cached = _SINGLETON_CACHE.get("sp") - if cached is None: - shared_preferences_module = import_module(".utils.shared_preferences", __name__) - cached = shared_preferences_module.SharedPreferences(db_helper=_get_db_helper()) - _SINGLETON_CACHE["sp"] = cached - return cached - - -def _get_file_token_service(): - cached = _SINGLETON_CACHE.get("file_token_service") - if cached is None: - service_module = import_module(".file_token_service", __name__) - cached = service_module.FileTokenService() - _SINGLETON_CACHE["file_token_service"] = cached - return cached - - -def _get_html_renderer(): - cached = _SINGLETON_CACHE.get("html_renderer") - if cached is None: - renderer_module = import_module(".utils.t2i.renderer", __name__) - config = _get_astrbot_config() - endpoint = config.get("t2i_endpoint", "https://t2i.soulter.top/text2img") - cached = renderer_module.HtmlRenderer(endpoint) - _SINGLETON_CACHE["html_renderer"] = cached - return cached - - -def _get_pip_installer(): - cached = _SINGLETON_CACHE.get("pip_installer") - if cached is None: - installer_module = import_module(".utils.pip_installer", __name__) - config = _get_astrbot_config() - cached = installer_module.PipInstaller( - config.get("pip_install_arg", ""), - config.get("pypi_index_url", None), - ) - _SINGLETON_CACHE["pip_installer"] = cached - return cached - - -def __getattr__(name: str) -> Any: - if name == "AstrBotConfig": - return import_module(".config", __name__).AstrBotConfig - if name in {"LogBroker", "LogManager"}: - module = import_module(".log", __name__) - return getattr(module, name) - if name == "DependencyConflictError": - return import_module(".utils.pip_installer", __name__).DependencyConflictError - if name == "FileTokenService": - return import_module(".file_token_service", __name__).FileTokenService - if name == "PipInstaller": - return import_module(".utils.pip_installer", __name__).PipInstaller - if name == "RequirementsPrecheckFailed": - return import_module( - ".utils.requirements_utils", __name__ - ).RequirementsPrecheckFailed - if name == "SQLiteDatabase": - return import_module(".db.sqlite", __name__).SQLiteDatabase - if name == "find_missing_requirements": - return import_module( - ".utils.requirements_utils", __name__ - ).find_missing_requirements - if name == "find_missing_requirements_or_raise": - return import_module( - ".utils.requirements_utils", __name__ - ).find_missing_requirements_or_raise - if name == "astrbot_config": - return _get_astrbot_config() - if name == "logger": - return _get_logger() - if name == "db_helper": - return _get_db_helper() - if name == "sp": - return _get_shared_preferences() - if name == "file_token_service": - return _get_file_token_service() - if name == "html_renderer": - return _get_html_renderer() - if name == "pip_installer": - return _get_pip_installer() - raise AttributeError(name) "t2i_base_url", ] diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 16e8a01d2..feb8d3ed5 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -1,6 +1,6 @@ import copy from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any, Generic +from typing import Any, Generic, cast import jsonschema import mcp @@ -145,8 +145,8 @@ class ToolSet: light_tools.append( FunctionTool( name=tool.name, - parameters=light_params, description=tool.description, + parameters=light_params, handler=None, ) ) @@ -166,8 +166,8 @@ class ToolSet: param_tools.append( FunctionTool( name=tool.name, - parameters=params, description="", + parameters=params, handler=None, ) ) @@ -218,7 +218,10 @@ class ToolSet: """Convert tools to OpenAI API function calling schema format.""" result = [] for tool in self.tools: - func_def = {"type": "function", "function": {"name": tool.name}} + func_def: dict[str, Any] = { + "type": "function", + "function": {"name": tool.name}, + } if tool.description: func_def["function"]["description"] = tool.description @@ -226,7 +229,7 @@ class ToolSet: if ( tool.parameters and tool.parameters.get("properties") ) or not omit_empty_parameter_field: - func_def["function"]["parameters"] = tool.parameters + func_def["function"]["parameters"] = tool.parameters # type: ignore[index] result.append(func_def) return result diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 1cb0a9724..3698aaead 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -27,6 +27,17 @@ local_booter: ComputerBooter | None = None _MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json" +def _list_local_skill_dirs(skills_root: Path) -> list[Path]: + skills: list[Path] = [] + for entry in sorted(skills_root.iterdir()): + if not entry.is_dir(): + continue + skill_md = entry / "SKILL.md" + if skill_md.exists(): + skills.append(entry) + return skills + + def _discover_bay_credentials(endpoint: str) -> str: """Try to auto-discover Bay API key from credentials.json. @@ -388,7 +399,6 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: await temp_dir.mkdir(parents=True, exist_ok=True) zip_base = temp_dir / "skills_bundle" zip_path = zip_base.with_suffix(".zip") - bundle_dir = temp_dir / f"skills_bundle_{uuid.uuid4().hex}" try: if local_skill_dirs: @@ -420,8 +430,6 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: len(managed), ) finally: - if bundle_dir.exists(): - shutil.rmtree(bundle_dir, ignore_errors=True) if zip_path.exists(): try: zip_path.unlink() @@ -495,12 +503,6 @@ async def get_booter( from .booters.boxlite import BoxliteBooter client = BoxliteBooter() - elif booter_type == "bwrap": - from .booters.bwrap import BwrapBooter - - rw_binds = sandbox_cfg.get("bwrap_rw_binds", []) - ro_binds = sandbox_cfg.get("bwrap_ro_binds", []) - client = BwrapBooter(rw_binds=rw_binds, ro_binds=ro_binds) else: raise ValueError(f"Unknown booter type: {booter_type}") diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 1ff0d7114..c4d587725 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -16,7 +16,8 @@ import time import traceback from asyncio import Queue -from astrbot.core import LogBroker, LogManager, logger, sp +from astrbot.api import logger, sp +from astrbot.core import LogBroker, LogManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.config.default import VERSION from astrbot.core.conversation_mgr import ConversationManager @@ -28,7 +29,6 @@ from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler from astrbot.core.platform.manager import PlatformManager from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.provider.manager import ProviderManager -from astrbot.core.sdk_bridge import SdkPluginBridge from astrbot.core.star.context import Context from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.star.star_manager import PluginManager @@ -200,11 +200,6 @@ class AstrBotCoreLifecycle: # 扫描、注册插件、实例化插件类 await self.plugin_manager.reload() - self.sdk_plugin_bridge = SdkPluginBridge(self.star_context) - self.star_context.sdk_plugin_bridge = self.sdk_plugin_bridge - self.platform_manager.sdk_plugin_bridge = self.sdk_plugin_bridge - await self.sdk_plugin_bridge.start() - # 根据配置实例化各个 Provider await self.provider_manager.initialize() @@ -316,12 +311,6 @@ class AstrBotCoreLifecycle: except BaseException: logger.error(traceback.format_exc()) - if getattr(self, "sdk_plugin_bridge", None) is not None: - try: - await self.sdk_plugin_bridge.dispatch_system_event("astrbot_loaded") - except Exception as exc: - logger.warning(f"SDK astrbot_loaded event dispatch failed: {exc}") - # 同时运行curr_tasks中的所有任务 await asyncio.gather(*self.curr_tasks, return_exceptions=True) @@ -337,9 +326,6 @@ class AstrBotCoreLifecycle: if self.cron_manager: await self.cron_manager.shutdown() - if getattr(self, "sdk_plugin_bridge", None) is not None: - await self.sdk_plugin_bridge.stop() - for plugin in self.plugin_manager.context.get_all_stars(): try: await self.plugin_manager._terminate_plugin(plugin) @@ -365,8 +351,6 @@ class AstrBotCoreLifecycle: async def restart(self) -> None: """重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例""" - if getattr(self, "sdk_plugin_bridge", None) is not None: - await self.sdk_plugin_bridge.stop() await self.provider_manager.terminate() await self.platform_manager.terminate() await self.kb_manager.terminate() diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index acdafe0ea..a0353830f 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -53,9 +53,6 @@ class RespondStage(Stage): async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config - self.sdk_plugin_bridge = getattr( - ctx.plugin_manager.context, "sdk_plugin_bridge", None - ) self.platform_settings: dict = self.config.get("platform_settings", {}) self.reply_with_mention = ctx.astrbot_config["platform_settings"][ @@ -131,36 +128,12 @@ class RespondStage(Stage): # 如果所有组件都为空 return True - @staticmethod - def _message_outline_for_sdk_event( - chain: MessageChain | list[BaseMessageComponent] | None, - ) -> str: - if isinstance(chain, MessageChain): - return chain.get_plain_text(with_other_comps_mark=True) - if isinstance(chain, list): - return MessageChain(chain).get_plain_text(with_other_comps_mark=True) - return "" - - @staticmethod - def _message_payloads_for_sdk_event( - chain: MessageChain | list[BaseMessageComponent] | None, - ) -> list[dict]: - from astrbot_sdk.message.components import component_to_payload_sync - - if isinstance(chain, MessageChain): - components = chain.chain - elif isinstance(chain, list): - components = chain - else: - components = [] - return [component_to_payload_sync(component) for component in components] - def is_seg_reply_required(self, event: AstrMessageEvent) -> bool: """检查是否需要分段回复""" if not self.enable_seg: return False - if (result := self._get_effective_result(event)) is None: + if (result := event.get_result()) is None: return False if self.only_llm_result and not result.is_model_result(): return False @@ -198,7 +171,7 @@ class RespondStage(Stage): self, event: AstrMessageEvent, ) -> None | AsyncGenerator[None, None]: - result = self._get_effective_result(event) + result = event.get_result() if result is None: return if event.get_extra("_streaming_finished", False): @@ -320,30 +293,4 @@ class RespondStage(Stage): if await call_event_hook(event, EventType.OnAfterMessageSentEvent): return - if self.sdk_plugin_bridge is not None: - try: - await self.sdk_plugin_bridge.dispatch_message_event( - "after_message_sent", - event, - { - "session_id": event.unified_msg_origin, - "platform": event.get_platform_name(), - "platform_id": event.get_platform_id(), - "message_type": event.get_message_type().value, - "sender_name": event.get_sender_name(), - "self_id": event.get_self_id(), - "message_outline": self._message_outline_for_sdk_event( - result.chain - ), - "sent_message_outline": self._message_outline_for_sdk_event( - result.chain - ), - "sent_messages": self._message_payloads_for_sdk_event( - result.chain - ), - }, - ) - except Exception as exc: - logger.warning(f"SDK after_message_sent dispatch failed: {exc}") - event.clear_result() diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 1a525adf7..cfb5f0c35 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -2,7 +2,6 @@ import asyncio import traceback from asyncio import Queue from dataclasses import dataclass -from typing import TYPE_CHECKING from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -13,8 +12,6 @@ from .platform import Platform, PlatformStatus from .register import platform_cls_map from .sources.webchat.webchat_adapter import WebChatAdapter -if TYPE_CHECKING: - from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge PLATFORM_ADAPTER_MODULES: dict[str, str] = { "aiocqhttp": ".sources.aiocqhttp.aiocqhttp_platform_adapter", "qq_official": ".sources.qqofficial.qqofficial_platform_adapter", @@ -55,7 +52,6 @@ class PlatformManager: 这个配置中的 unique_session 需要特殊处理, 约定整个项目中对 unique_session 的引用都从 default 的配置中获取""" self.event_queue = event_queue - self.sdk_plugin_bridge: SdkPluginBridge | None = None def _is_valid_platform_id(self, platform_id: str | None) -> bool: if not platform_id: @@ -224,7 +220,6 @@ class PlatformManager: return cls_type = platform_cls_map[platform_config["type"]] inst: Platform = cls_type(platform_config, self.settings, self.event_queue) - setattr(inst, "sdk_plugin_bridge", self.sdk_plugin_bridge) self._inst_map[platform_config["id"]] = { "inst": inst, "client_id": inst.client_self_id, @@ -245,17 +240,6 @@ class PlatformManager: await handler.handler() except Exception: logger.error(traceback.format_exc()) - if self.sdk_plugin_bridge is not None: - try: - await self.sdk_plugin_bridge.dispatch_system_event( - "platform_loaded", - { - "platform": inst.meta().name, - "platform_id": inst.meta().id, - }, - ) - except Exception as exc: - logger.warning(f"SDK platform_loaded event dispatch failed: {exc}") async def _task_wrapper( self, task: asyncio.Task, platform: Platform | None = None diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py index c674cd819..ad8bb44f6 100644 --- a/astrbot/core/platform_message_history_mgr.py +++ b/astrbot/core/platform_message_history_mgr.py @@ -1,232 +1,8 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - -from astrbot_sdk.message.components import component_to_payload_sync - from astrbot.core.db import BaseDatabase from astrbot.core.db.po import PlatformMessageHistory -from astrbot.core.message.components import ( - At, - AtAll, - BaseMessageComponent, - File, - Forward, - Image, - Plain, - Poke, - Record, - Reply, - Unknown, - Video, -) -from astrbot.core.platform.message_session import MessageSession -from astrbot.core.platform.message_type import MessageType - - -@dataclass(frozen=True, slots=True) -class MessageHistorySender: - sender_id: str | None = None - sender_name: str | None = None - - -@dataclass(slots=True) -class MessageHistoryRecord: - id: int - session: MessageSession - sender: MessageHistorySender - parts: list[BaseMessageComponent] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - created_at: datetime | None = None - updated_at: datetime | None = None - idempotency_key: str | None = None - - -@dataclass(frozen=True, slots=True) -class MessageHistoryPage: - records: list[MessageHistoryRecord] - next_cursor: str | None - total: int | None - - -def _message_type_key(value: MessageType | str) -> str: - if isinstance(value, MessageType): - if value == MessageType.GROUP_MESSAGE: - return "group" - if value == MessageType.FRIEND_MESSAGE: - return "private" - return "other" - normalized = str(value).strip().lower() - if normalized in {"group", "groupmessage", "group_message"}: - return "group" - if normalized in { - "private", - "friend", - "friendmessage", - "privatemessage", - "friend_message", - "private_message", - }: - return "private" - if normalized in {"other", "othermessage", "other_message"}: - return "other" - raise ValueError(f"Unsupported message type: {value}") - - -def _message_type_enum(value: str) -> MessageType: - normalized = _message_type_key(value) - if normalized == "group": - return MessageType.GROUP_MESSAGE - if normalized == "private": - return MessageType.FRIEND_MESSAGE - return MessageType.OTHER_MESSAGE - - -def _session_storage_key(session: MessageSession) -> str: - # TODO(refactor): persist message_type as a first-class column once the - # legacy message history model can be migrated without impacting old plugins. - return f"{_message_type_key(session.message_type)}:{session.session_id}" - - -def _optional_int_cursor(cursor: str | None) -> int | None: - if cursor is None: - return None - text = str(cursor).strip() - if not text: - return None - return int(text) - - -def _payload_to_component(payload: Any) -> BaseMessageComponent: - if not isinstance(payload, dict): - return Unknown(text=str(payload)) - - raw_type = str(payload.get("type", "unknown") or "unknown").lower() - data = payload.get("data") - if not isinstance(data, dict): - data = {} - - if raw_type in {"text", "plain"}: - return Plain(str(data.get("text", "")), convert=False) - if raw_type == "image": - image_data = dict(data) - image_file = str(image_data.pop("file", "") or image_data.get("url") or "") - return Image(image_file, **image_data) - if raw_type == "at": - qq_value = data.get("qq") - if str(qq_value).lower() == "all": - return AtAll() - return At(qq=str(qq_value or ""), name=str(data.get("name", ""))) - if raw_type == "reply": - reply_data = dict(data) - chain_payload = reply_data.get("chain") - reply_data["chain"] = ( - [_payload_to_component(item) for item in chain_payload] - if isinstance(chain_payload, list) - else [] - ) - return Reply(**reply_data) - if raw_type == "record": - record_data = dict(data) - record_file = str(record_data.pop("file", "") or record_data.get("url") or "") - return Record(record_file, **record_data) - if raw_type == "video": - video_data = dict(data) - video_file = str(video_data.pop("file", "") or "") - return Video(video_file, **video_data) - if raw_type == "file": - file_value = str(data.get("file") or data.get("file_") or data.get("url") or "") - return File( - str(data.get("name", "") or "file"), - file="" if file_value.startswith(("http://", "https://")) else file_value, - url=file_value if file_value.startswith(("http://", "https://")) else "", - ) - if raw_type == "poke": - return Poke( - poke_type=data.get("type"), - id=data.get("id"), - qq=data.get("qq"), - ) - if raw_type == "forward": - return Forward(id=str(data.get("id", ""))) - return Unknown(text=str(payload)) - - -def _legacy_content_to_payloads( - content: dict[str, Any], -) -> tuple[list[dict[str, Any]], dict[str, Any]]: - message_parts = content.get("message") - if not isinstance(message_parts, list): - return [], {} - payloads: list[dict[str, Any]] = [] - for part in message_parts: - if not isinstance(part, dict): - continue - part_type = str(part.get("type", "")).strip().lower() - if part_type == "plain": - text = str(part.get("text", "")) - if text: - payloads.append({"type": "text", "data": {"text": text}}) - continue - if part_type == "reply": - message_id = part.get("message_id") - if message_id is None: - continue - payloads.append( - { - "type": "reply", - "data": { - "id": str(message_id), - "message_str": str(part.get("selected_text", "")), - "chain": [], - }, - } - ) - continue - if part_type not in {"image", "record", "file", "video"}: - continue - payload_data: dict[str, Any] = {} - attachment_id = part.get("attachment_id") - if attachment_id is not None: - payload_data["attachment_id"] = str(attachment_id) - filename = part.get("filename") - if filename is not None: - payload_data["filename"] = str(filename) - if part_type == "file": - payload_data["name"] = str(filename) - path_value = part.get("path") - if path_value not in (None, ""): - payload_data["path"] = str(path_value) - payload_data["file"] = str(path_value) - payloads.append({"type": part_type, "data": payload_data}) - metadata = {key: value for key, value in content.items() if key != "message"} - return payloads, metadata - - -def _content_to_parts_and_metadata( - content: Any, -) -> tuple[list[dict[str, Any]], dict[str, Any], str | None]: - if not isinstance(content, dict): - return [], {}, None - if isinstance(content.get("parts"), list): - metadata = content.get("metadata") - idempotency_key = content.get("idempotency_key") - return ( - [dict(item) for item in content["parts"] if isinstance(item, dict)], - dict(metadata) if isinstance(metadata, dict) else {}, - str(idempotency_key) if idempotency_key is not None else None, - ) - payloads, metadata = _legacy_content_to_payloads(content) - return payloads, metadata, None class PlatformMessageHistoryManager: - MessageHistorySender = MessageHistorySender - MessageHistoryRecord = MessageHistoryRecord - MessageHistoryPage = MessageHistoryPage - def __init__(self, db_helper: BaseDatabase) -> None: self.db = db_helper @@ -234,7 +10,7 @@ class PlatformMessageHistoryManager: self, platform_id: str, user_id: str, - content: dict, + content: dict, # TODO: parse from message chain sender_id: str | None = None, sender_name: str | None = None, ) -> PlatformMessageHistory: @@ -273,146 +49,3 @@ class PlatformMessageHistoryManager: user_id=user_id, offset_sec=offset_sec, ) - - async def append( - self, - session: MessageSession, - *, - parts: list[BaseMessageComponent], - sender: MessageHistorySender, - metadata: dict[str, Any] | None = None, - idempotency_key: str | None = None, - ) -> MessageHistoryRecord: - storage_user_id = _session_storage_key(session) - if idempotency_key: - # TODO(refactor): move idempotency_key into a dedicated indexed column - # after the legacy history table is migrated for the new SDK path. - existing = await self.db.find_platform_message_history_by_idempotency_key( - platform_id=session.platform_id, - user_id=storage_user_id, - idempotency_key=idempotency_key, - ) - if existing is not None: - return self._record_from_model(existing) - - content = { - "parts": [component_to_payload_sync(part) for part in parts], - "metadata": dict(metadata or {}), - } - if idempotency_key is not None: - content["idempotency_key"] = str(idempotency_key) - - record = await self.db.insert_platform_message_history( - platform_id=session.platform_id, - user_id=storage_user_id, - content=content, - sender_id=sender.sender_id, - sender_name=sender.sender_name, - ) - return self._record_from_model(record) - - async def list( - self, - session: MessageSession, - *, - cursor: str | None = None, - limit: int = 50, - ) -> MessageHistoryPage: - normalized_limit = max(1, int(limit)) - rows, total = await self.db.list_sdk_platform_message_history( - platform_id=session.platform_id, - user_id=_session_storage_key(session), - cursor_id=_optional_int_cursor(cursor), - limit=normalized_limit + 1, - include_total=True, - ) - has_more = len(rows) > normalized_limit - page_rows = rows[:normalized_limit] - records = [self._record_from_model(row) for row in page_rows] - next_cursor = str(page_rows[-1].id) if has_more and page_rows else None - return MessageHistoryPage(records=records, next_cursor=next_cursor, total=total) - - async def get_by_id( - self, - session: MessageSession, - record_id: int, - ) -> MessageHistoryRecord | None: - record = await self.db.get_platform_message_history_by_id(int(record_id)) - if record is None: - return None - if record.platform_id != session.platform_id: - return None - if record.user_id != _session_storage_key(session): - return None - return self._record_from_model(record) - - async def delete_before( - self, - session: MessageSession, - *, - before: datetime, - ) -> int: - return await self.db.delete_platform_message_before( - platform_id=session.platform_id, - user_id=_session_storage_key(session), - before=before, - ) - - async def delete_after( - self, - session: MessageSession, - *, - after: datetime, - ) -> int: - return await self.db.delete_platform_message_after( - platform_id=session.platform_id, - user_id=_session_storage_key(session), - after=after, - ) - - async def delete_all(self, session: MessageSession) -> int: - return await self.db.delete_all_platform_message_history( - platform_id=session.platform_id, - user_id=_session_storage_key(session), - ) - - def _record_from_model( - self, record: PlatformMessageHistory - ) -> MessageHistoryRecord: - parts_payload, metadata, idempotency_key = _content_to_parts_and_metadata( - record.content - ) - return MessageHistoryRecord( - id=int(record.id or 0), - session=self._session_from_storage_record(record), - sender=MessageHistorySender( - sender_id=str(record.sender_id) - if record.sender_id is not None - else None, - sender_name=( - str(record.sender_name) if record.sender_name is not None else None - ), - ), - parts=[_payload_to_component(item) for item in parts_payload], - metadata=metadata, - created_at=record.created_at, - updated_at=record.updated_at, - idempotency_key=idempotency_key, - ) - - def _session_from_storage_record( - self, record: PlatformMessageHistory - ) -> MessageSession: - raw_user_id = str(record.user_id or "") - message_type = "private" - session_id = raw_user_id - if ":" in raw_user_id: - maybe_message_type, maybe_session_id = raw_user_id.split(":", 1) - if maybe_message_type in {"group", "private", "other"} and maybe_session_id: - message_type = maybe_message_type - session_id = maybe_session_id - return MessageSession( - platform_name=str(record.platform_id), - message_type=_message_type_enum(message_type), - session_id=session_id, - ) diff --git a/astrbot/core/sdk_bridge/__init__.py b/astrbot/core/sdk_bridge/__init__.py deleted file mode 100644 index 32b79b238..000000000 --- a/astrbot/core/sdk_bridge/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""SDK bridge package public exports.""" - -from __future__ import annotations - -from importlib import import_module -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .capability_bridge import CoreCapabilityBridge - from .event_converter import EventConverter - from .plugin_bridge import SdkPluginBridge - from .trigger_converter import TriggerConverter -else: - CoreCapabilityBridge: Any - EventConverter: Any - SdkPluginBridge: Any - TriggerConverter: Any - -__all__ = [ - "CoreCapabilityBridge", - "EventConverter", - "SdkPluginBridge", - "TriggerConverter", -] - - -def __getattr__(name: str) -> Any: - if name == "CoreCapabilityBridge": - return import_module(".capability_bridge", __name__).CoreCapabilityBridge - if name == "EventConverter": - return import_module(".event_converter", __name__).EventConverter - if name == "SdkPluginBridge": - return import_module(".plugin_bridge", __name__).SdkPluginBridge - if name == "TriggerConverter": - return import_module(".trigger_converter", __name__).TriggerConverter - raise AttributeError(name) diff --git a/astrbot/core/sdk_bridge/bridge_base.py b/astrbot/core/sdk_bridge/bridge_base.py deleted file mode 100644 index db1ead5d2..000000000 --- a/astrbot/core/sdk_bridge/bridge_base.py +++ /dev/null @@ -1,578 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import json -from collections.abc import Iterable -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, cast - -from astrbot_sdk._internal.invocation_context import current_caller_plugin_id -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.runtime.capability_router import CapabilityRouter - -from astrbot.core.file_token_service import FileTokenService -from astrbot.core.message.components import ComponentTypes, Image, Plain -from astrbot.core.message.message_event_result import MessageChain - -if TYPE_CHECKING: - from astrbot.core.star.context import Context as StarContext - - -def _get_runtime_sp(): - from astrbot.core import sp - - return sp - - -def _get_runtime_html_renderer(): - from astrbot.core import html_renderer - - return html_renderer - - -def _get_runtime_astrbot_config(): - from astrbot.core import astrbot_config - - return astrbot_config - - -def _get_runtime_file_token_service() -> FileTokenService: - from astrbot.core import file_token_service - - return cast(FileTokenService, file_token_service) - - -def _get_runtime_tool_types(): - from astrbot.core.agent.tool import FunctionTool, ToolSet - - return FunctionTool, ToolSet - - -def _get_runtime_provider_types(): - from astrbot.core.provider.provider import ( - EmbeddingProvider, - RerankProvider, - STTProvider, - TTSProvider, - ) - - return STTProvider, TTSProvider, EmbeddingProvider, RerankProvider - - -@dataclass(slots=True) -class _EventStreamState: - request_context: Any - queue: asyncio.Queue[MessageChain | None] - task: asyncio.Task[None] - - -def _build_message_chain_from_payload( - chain_payload: list[dict[str, Any]], -) -> MessageChain: - components = [] - for item in chain_payload: - if not isinstance(item, dict): - continue - comp_type = str(item.get("type", "")).lower() - data = item.get("data", {}) - if comp_type in {"text", "plain"} and isinstance(data, dict): - components.append(Plain(str(data.get("text", "")), convert=False)) - continue - if comp_type == "image" and isinstance(data, dict): - file_value = str(data.get("file") or data.get("url") or "") - if file_value.startswith(("http://", "https://")): - components.append(Image.fromURL(file_value)) - elif file_value: - file_path = ( - file_value[8:] if file_value.startswith("file:///") else file_value - ) - components.append(Image.fromFileSystem(file_path)) - continue - component_cls = ComponentTypes.get(comp_type) - if component_cls is None: - components.append( - Plain(json.dumps(item, ensure_ascii=False), convert=False) - ) - continue - try: - if isinstance(data, dict): - components.append(component_cls(**data)) - else: - components.append(Plain(str(item), convert=False)) - except Exception: - components.append( - Plain(json.dumps(item, ensure_ascii=False), convert=False) - ) - return MessageChain(components) - - -class CapabilityBridgeBase(CapabilityRouter): - MEMORY_SCOPE = "sdk_memory" - - _star_context: StarContext - _plugin_bridge: Any - - @staticmethod - def _to_iso_datetime(value: Any) -> str | None: - if value is None: - return None - isoformat = getattr(value, "isoformat", None) - if callable(isoformat): - return str(isoformat()) - if isinstance(value, (int, float)) and value > 0: - return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat() - return None - - @staticmethod - def _optional_int(value: Any) -> int | None: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _normalize_history_items(value: Any) -> list[dict[str, Any]]: - if isinstance(value, list): - return [dict(item) for item in value if isinstance(item, dict)] - if isinstance(value, str): - with contextlib.suppress(json.JSONDecodeError, TypeError, ValueError): - decoded = json.loads(value) - if isinstance(decoded, list): - return [dict(item) for item in decoded if isinstance(item, dict)] - return [] - - @staticmethod - def _normalize_persona_dialogs(value: Any) -> list[str]: - if isinstance(value, list): - return [str(item) for item in value if isinstance(item, str)] - if isinstance(value, str): - with contextlib.suppress(json.JSONDecodeError, TypeError, ValueError): - decoded = json.loads(value) - if isinstance(decoded, list): - return [str(item) for item in decoded if isinstance(item, str)] - return [] - - @staticmethod - def _normalize_session_scoped_config( - raw_config: Any, - session_id: str, - ) -> dict[str, Any]: - if not isinstance(raw_config, dict): - return {} - nested = raw_config.get(session_id) - if isinstance(nested, dict): - return dict(nested) - # Session plugin config is stored as {session_id: {...}}, but session - # service config already lives directly under the per-session storage key. - # Accept both shapes so the bridge stays compatible with existing data. - return dict(raw_config) - - def _serialize_persona(self, persona: Any) -> dict[str, Any] | None: - if persona is None: - return None - return { - "persona_id": str(getattr(persona, "persona_id", "") or ""), - "system_prompt": str(getattr(persona, "system_prompt", "") or ""), - "begin_dialogs": self._normalize_persona_dialogs( - getattr(persona, "begin_dialogs", None) - ), - "tools": ( - [str(item) for item in getattr(persona, "tools", [])] - if isinstance(getattr(persona, "tools", None), list) - else None - ), - "skills": ( - [str(item) for item in getattr(persona, "skills", [])] - if isinstance(getattr(persona, "skills", None), list) - else None - ), - "custom_error_message": ( - str(getattr(persona, "custom_error_message", "")) - if getattr(persona, "custom_error_message", None) is not None - else None - ), - "folder_id": ( - str(getattr(persona, "folder_id", "")) - if getattr(persona, "folder_id", None) is not None - else None - ), - "sort_order": int(getattr(persona, "sort_order", 0) or 0), - "created_at": self._to_iso_datetime(getattr(persona, "created_at", None)), - "updated_at": self._to_iso_datetime(getattr(persona, "updated_at", None)), - } - - def _serialize_conversation(self, conversation: Any) -> dict[str, Any] | None: - if conversation is None: - return None - return { - "conversation_id": str(getattr(conversation, "cid", "") or ""), - "session": str(getattr(conversation, "user_id", "") or ""), - "platform_id": str(getattr(conversation, "platform_id", "") or ""), - "history": self._normalize_history_items( - getattr(conversation, "history", None) - ), - "title": ( - str(getattr(conversation, "title", "")) - if getattr(conversation, "title", None) is not None - else None - ), - "persona_id": ( - str(getattr(conversation, "persona_id", "")) - if getattr(conversation, "persona_id", None) is not None - else None - ), - "created_at": self._to_iso_datetime( - getattr(conversation, "created_at", None) - ), - "updated_at": self._to_iso_datetime( - getattr(conversation, "updated_at", None) - ), - "token_usage": ( - int(getattr(conversation, "token_usage")) - if getattr(conversation, "token_usage", None) is not None - else None - ), - } - - def _serialize_kb(self, kb_helper_or_record: Any) -> dict[str, Any] | None: - kb = getattr(kb_helper_or_record, "kb", kb_helper_or_record) - if kb is None: - return None - return { - "kb_id": str(getattr(kb, "kb_id", "") or ""), - "kb_name": str(getattr(kb, "kb_name", "") or ""), - "description": ( - str(getattr(kb, "description", "")) - if getattr(kb, "description", None) is not None - else None - ), - "emoji": ( - str(getattr(kb, "emoji", "")) - if getattr(kb, "emoji", None) is not None - else None - ), - "embedding_provider_id": str( - getattr(kb, "embedding_provider_id", "") or "" - ), - "rerank_provider_id": ( - str(getattr(kb, "rerank_provider_id", "")) - if getattr(kb, "rerank_provider_id", None) is not None - else None - ), - "chunk_size": ( - int(getattr(kb, "chunk_size")) - if getattr(kb, "chunk_size", None) is not None - else None - ), - "chunk_overlap": ( - int(getattr(kb, "chunk_overlap")) - if getattr(kb, "chunk_overlap", None) is not None - else None - ), - "top_k_dense": ( - int(getattr(kb, "top_k_dense")) - if getattr(kb, "top_k_dense", None) is not None - else None - ), - "top_k_sparse": ( - int(getattr(kb, "top_k_sparse")) - if getattr(kb, "top_k_sparse", None) is not None - else None - ), - "top_m_final": ( - int(getattr(kb, "top_m_final")) - if getattr(kb, "top_m_final", None) is not None - else None - ), - "doc_count": int(getattr(kb, "doc_count", 0) or 0), - "chunk_count": int(getattr(kb, "chunk_count", 0) or 0), - "created_at": self._to_iso_datetime(getattr(kb, "created_at", None)), - "updated_at": self._to_iso_datetime(getattr(kb, "updated_at", None)), - } - - def _serialize_kb_document(self, document: Any) -> dict[str, Any] | None: - if document is None: - return None - return { - "doc_id": str(getattr(document, "doc_id", "") or ""), - "kb_id": str(getattr(document, "kb_id", "") or ""), - "doc_name": str(getattr(document, "doc_name", "") or ""), - "file_type": str(getattr(document, "file_type", "") or ""), - "file_size": int(getattr(document, "file_size", 0) or 0), - "file_path": str(getattr(document, "file_path", "") or ""), - "chunk_count": int(getattr(document, "chunk_count", 0) or 0), - "media_count": int(getattr(document, "media_count", 0) or 0), - "created_at": self._to_iso_datetime(getattr(document, "created_at", None)), - "updated_at": self._to_iso_datetime(getattr(document, "updated_at", None)), - } - - @staticmethod - def _serialize_member(member: Any) -> dict[str, Any] | None: - if member is None: - return None - user_id = getattr(member, "user_id", None) - if user_id is None and isinstance(member, dict): - user_id = member.get("user_id") - if user_id is None: - return None - nickname = getattr(member, "nickname", None) - if nickname is None and isinstance(member, dict): - nickname = member.get("nickname") - role = getattr(member, "role", None) - if role is None and isinstance(member, dict): - role = member.get("role") - return { - "user_id": str(user_id), - "nickname": str(nickname or ""), - "role": str(role or ""), - } - - @classmethod - def _serialize_group(cls, group: Any) -> dict[str, Any] | None: - if group is None: - return None - members_payload = [] - raw_members = getattr(group, "members", None) - if raw_members is None: - raw_members = getattr(group, "member_list", None) - if raw_members is None and isinstance(group, dict): - raw_members = group.get("members") or group.get("member_list") - if isinstance(raw_members, list): - for member in raw_members: - serialized_member = cls._serialize_member(member) - if serialized_member is not None: - members_payload.append(serialized_member) - group_id = getattr(group, "group_id", None) - if group_id is None and isinstance(group, dict): - group_id = group.get("group_id") - group_name = getattr(group, "group_name", None) - if group_name is None and isinstance(group, dict): - group_name = group.get("group_name") - group_avatar = getattr(group, "group_avatar", None) - if group_avatar is None and isinstance(group, dict): - group_avatar = group.get("group_avatar") - group_owner = getattr(group, "group_owner", None) - if group_owner is None and isinstance(group, dict): - group_owner = group.get("group_owner") - group_admins = getattr(group, "group_admins", None) - if group_admins is None and isinstance(group, dict): - group_admins = group.get("group_admins") - return { - "group_id": str(group_id or ""), - "group_name": str(group_name or ""), - "group_avatar": str(group_avatar or ""), - "group_owner": str(group_owner or ""), - "group_admins": ( - [str(item) for item in group_admins] - if isinstance(group_admins, list) - else [] - ), - "members": members_payload, - } - - @staticmethod - def _serialize_platform_error(error: Any) -> dict[str, Any] | None: - if error is None: - return None - message = getattr(error, "message", None) - timestamp = getattr(error, "timestamp", None) - traceback_value = getattr(error, "traceback", None) - if isinstance(error, dict): - message = error.get("message", message) - timestamp = error.get("timestamp", timestamp) - traceback_value = error.get("traceback", traceback_value) - if not message: - return None - return { - "message": str(message), - "timestamp": CapabilityBridgeBase._to_iso_datetime(timestamp) - or str(timestamp or ""), - "traceback": ( - str(traceback_value) if traceback_value is not None else None - ), - } - - @classmethod - def _serialize_platform_snapshot(cls, platform: Any) -> dict[str, Any] | None: - if platform is None: - return None - meta = None - try: - meta = platform.meta() - except Exception: - meta = None - platform_id = str( - getattr(meta, "id", None) or getattr(platform, "config", {}).get("id", "") - ).strip() - platform_type = str(getattr(meta, "name", "") or "").strip() - if not platform_id or not platform_type: - return None - status = getattr(platform, "status", None) - errors = getattr(platform, "errors", []) - status_value = getattr(status, "value", status) - return { - "id": platform_id, - "name": str(getattr(meta, "adapter_display_name", None) or platform_type), - "type": platform_type, - "status": str(status_value or "pending"), - "errors": [ - payload - for payload in ( - cls._serialize_platform_error(item) - for item in (errors if isinstance(errors, list) else []) - ) - if payload is not None - ], - "last_error": cls._serialize_platform_error( - getattr(platform, "last_error", None) - ), - "unified_webhook": bool( - platform.unified_webhook() - if hasattr(platform, "unified_webhook") - else False - ), - } - - @classmethod - def _serialize_platform_stats(cls, stats: Any) -> dict[str, Any] | None: - if not isinstance(stats, dict): - return None - payload = dict(stats) - payload["last_error"] = cls._serialize_platform_error(stats.get("last_error")) - meta = stats.get("meta") - payload["meta"] = dict(meta) if isinstance(meta, dict) else {} - return payload - - def _get_platform_inst_by_id(self, platform_id: str) -> Any | None: - platform_manager = getattr(self._star_context, "platform_manager", None) - if platform_manager is None or not hasattr(platform_manager, "get_insts"): - return None - normalized_platform_id = str(platform_id).strip() - if not normalized_platform_id: - return None - for platform in list(platform_manager.get_insts()): - meta = None - try: - meta = platform.meta() - except Exception: - continue - if str(getattr(meta, "id", "")).strip() == normalized_platform_id: - return platform - return None - - def _resolve_plugin_id(self, request_id: str) -> str: - plugin_id = current_caller_plugin_id() - if plugin_id: - return plugin_id - return self._plugin_bridge.resolve_request_plugin_id(request_id) - - def _reserved_plugin_names(self) -> set[str]: - reserved: set[str] = set() - get_all_stars = getattr(self._star_context, "get_all_stars", None) - if not callable(get_all_stars): - return reserved - stars = get_all_stars() - if not isinstance(stars, Iterable): - return reserved - for star in stars: - name = getattr(star, "name", None) - if name and bool(getattr(star, "reserved", False)): - reserved.add(str(name)) - return reserved - - def _require_reserved_plugin( - self, - request_id: str, - capability_name: str, - ) -> str: - plugin_id = self._resolve_plugin_id(request_id) - if plugin_id in {"system", "__system__"}: - return plugin_id - if plugin_id in self._reserved_plugin_names(): - return plugin_id - raise AstrBotError.invalid_input( - f"{capability_name} is restricted to reserved/system plugins" - ) - - def _resolve_dispatch_target( - self, - request_id: str, - payload: dict[str, Any], - ) -> tuple[str, str]: - target_payload = payload.get("target") - dispatch_token = "" - if isinstance(target_payload, dict): - raw_payload = target_payload.get("raw") - if isinstance(raw_payload, dict): - dispatch_token = str(raw_payload.get("dispatch_token", "")) - if not dispatch_token: - nested_raw_payload = raw_payload.get("raw") - if isinstance(nested_raw_payload, dict): - dispatch_token = str( - nested_raw_payload.get("dispatch_token", "") - ) - if not dispatch_token: - request_context = self._plugin_bridge.resolve_request_session(request_id) - if request_context is None: - raise AstrBotError.invalid_input( - "Missing dispatch token for platform send" - ) - dispatch_token = request_context.dispatch_token - session = str(payload.get("session", "")) - return session, dispatch_token - - def _resolve_event_request_context( - self, - request_id: str, - payload: dict[str, Any], - ): - def _has_event(request_context: Any | None) -> bool: - if request_context is None: - return False - has_event = getattr(request_context, "has_event", None) - if has_event is not None: - return bool(has_event) - return hasattr(request_context, "event") - - target_payload = payload.get("target") - dispatch_token = "" - if isinstance(target_payload, dict): - raw_payload = target_payload.get("raw") - if isinstance(raw_payload, dict): - dispatch_token = str(raw_payload.get("dispatch_token", "")) - if not dispatch_token: - nested_raw = raw_payload.get("raw") - if isinstance(nested_raw, dict): - dispatch_token = str(nested_raw.get("dispatch_token", "")) - if dispatch_token: - request_context = self._plugin_bridge.get_request_context_by_token( - dispatch_token - ) - return request_context if _has_event(request_context) else None - request_context = self._plugin_bridge.resolve_request_session(request_id) - return request_context if _has_event(request_context) else None - - def _resolve_current_group_request_context( - self, - request_id: str, - payload: dict[str, Any], - ): - request_context = self._resolve_event_request_context(request_id, payload) - if request_context is None: - return None - payload_session = str(payload.get("session", "")).strip() - if payload_session and payload_session != str( - request_context.event.unified_msg_origin - ): - raise AstrBotError.invalid_input( - "platform.get_group/get_members only support the current event session" - ) - return request_context - - @staticmethod - def _build_core_message_chain(chain_payload: list[dict[str, Any]]) -> MessageChain: - return _build_message_chain_from_payload(chain_payload) diff --git a/astrbot/core/sdk_bridge/capabilities/__init__.py b/astrbot/core/sdk_bridge/capabilities/__init__.py deleted file mode 100644 index e32e7e3b6..000000000 --- a/astrbot/core/sdk_bridge/capabilities/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from .basic import BasicCapabilityMixin -from .conversation import ConversationCapabilityMixin -from .kb import KnowledgeBaseCapabilityMixin -from .llm import LLMCapabilityMixin -from .mcp import MCPCapabilityMixin -from .message_history import MessageHistoryCapabilityMixin -from .persona import PersonaCapabilityMixin -from .platform import PlatformCapabilityMixin -from .provider import ProviderCapabilityMixin -from .session import SessionCapabilityMixin -from .skill import SkillCapabilityMixin -from .system import SystemCapabilityMixin - -__all__ = [ - "BasicCapabilityMixin", - "ConversationCapabilityMixin", - "KnowledgeBaseCapabilityMixin", - "LLMCapabilityMixin", - "MCPCapabilityMixin", - "MessageHistoryCapabilityMixin", - "PersonaCapabilityMixin", - "PlatformCapabilityMixin", - "ProviderCapabilityMixin", - "SessionCapabilityMixin", - "SkillCapabilityMixin", - "SystemCapabilityMixin", -] diff --git a/astrbot/core/sdk_bridge/capabilities/_host.py b/astrbot/core/sdk_bridge/capabilities/_host.py deleted file mode 100644 index 3fede08bb..000000000 --- a/astrbot/core/sdk_bridge/capabilities/_host.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - - class CapabilityMixinHost: - MEMORY_SCOPE: str - _event_streams: dict[str, Any] - _plugin_bridge: Any - _star_context: Any - _memory_backends_by_plugin: dict[str, Any] - _memory_index_by_plugin: dict[str, dict[str, dict[str, Any]]] - _memory_dirty_keys_by_plugin: dict[str, set[str]] - _memory_expires_at_by_plugin: dict[str, dict[str, Any]] - - def register( - self, - descriptor: Any, - *, - call_handler: Any = None, - stream_handler: Any = None, - finalize: Any = None, - exposed: bool = True, - ) -> None: ... - - def _builtin_descriptor( - self, - name: str, - description: str, - *, - supports_stream: bool = False, - cancelable: bool = False, - ) -> Any: ... - - def _resolve_plugin_id(self, request_id: str) -> str: ... - - def _resolve_dispatch_target( - self, - request_id: str, - payload: dict[str, Any], - ) -> tuple[str, str]: ... - - def _resolve_event_request_context( - self, - request_id: str, - payload: dict[str, Any], - ) -> Any: ... - - def _resolve_current_group_request_context( - self, - request_id: str, - payload: dict[str, Any], - ) -> Any: ... - - def _build_core_message_chain( - self, chain_payload: list[dict[str, Any]] - ) -> Any: ... - - def _serialize_group(self, group: Any) -> dict[str, Any] | None: ... - - def _require_reserved_plugin( - self, - request_id: str, - capability_name: str, - ) -> str: ... - - def _get_platform_inst_by_id(self, platform_id: str) -> Any | None: ... - - def _serialize_platform_snapshot( - self, platform: Any - ) -> dict[str, Any] | None: ... - - def _serialize_platform_stats(self, stats: Any) -> dict[str, Any] | None: ... - - def _normalize_session_scoped_config( - self, - raw_config: Any, - session_id: str, - ) -> dict[str, Any]: ... - - def _get_typed_provider( - self, - payload: dict[str, Any], - capability_name: str, - provider_label: str, - expected_type: type[Any], - ) -> Any: ... - - def _provider_embedding_get_embedding( - self, - request_id: str, - payload: dict[str, Any], - token: Any, - ) -> Awaitable[dict[str, Any]]: ... - - def _provider_embedding_get_embeddings( - self, - request_id: str, - payload: dict[str, Any], - token: Any, - ) -> Awaitable[dict[str, Any]]: ... - - def _reserved_plugin_names(self) -> set[str]: ... - - def _serialize_persona(self, persona: Any) -> dict[str, Any] | None: ... - - def _normalize_persona_dialogs(self, value: Any) -> list[str]: ... - - def _serialize_conversation( - self, conversation: Any - ) -> dict[str, Any] | None: ... - - def _normalize_history_items(self, value: Any) -> list[dict[str, Any]]: ... - - def _optional_int(self, value: Any) -> int | None: ... - - def _serialize_kb(self, kb_helper_or_record: Any) -> dict[str, Any] | None: ... - - def _serialize_kb_document(self, document: Any) -> dict[str, Any] | None: ... - -else: - - class CapabilityMixinHost: - # Keep the runtime host empty so it cannot shadow CapabilityRouter methods in - # CoreCapabilityBridge's MRO. The typed method declarations above are only for - # static analysis. - pass diff --git a/astrbot/core/sdk_bridge/capabilities/basic.py b/astrbot/core/sdk_bridge/capabilities/basic.py deleted file mode 100644 index 6817adf4e..000000000 --- a/astrbot/core/sdk_bridge/capabilities/basic.py +++ /dev/null @@ -1,610 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from astrbot_sdk._memory_backend import PluginMemoryBackend -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.runtime.capability_router import StreamExecution - -from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path - -from ..bridge_base import _get_runtime_provider_types, _get_runtime_sp -from ._host import CapabilityMixinHost - - -class BasicCapabilityMixin(CapabilityMixinHost): - def _memory_backend_for_plugin(self, plugin_id: str) -> PluginMemoryBackend: - backend = self._memory_backends_by_plugin.get(plugin_id) - if backend is None: - backend = PluginMemoryBackend( - Path(get_astrbot_plugin_data_path()) / plugin_id - ) - self._memory_backends_by_plugin[plugin_id] = backend - return backend - - def _resolve_memory_embedding_provider_id( - self, - payload: dict[str, Any], - *, - required: bool, - ) -> str | None: - provider_id = str(payload.get("provider_id", "")).strip() - _, _, embedding_provider_cls, _ = _get_runtime_provider_types() - if provider_id: - provider = self._star_context.get_provider_by_id(provider_id) - if provider is None or not isinstance(provider, embedding_provider_cls): - raise AstrBotError.invalid_input( - f"memory.search unknown embedding provider: {provider_id}" - ) - return provider_id - providers = self._star_context.get_all_embedding_providers() - if providers: - provider = providers[0] - provider_id = str(getattr(provider.meta(), "id", "") or "").strip() - if provider_id: - return provider_id - if required: - raise AstrBotError.invalid_input( - "memory.search requires an embedding provider", - ) - return None - - def _register_db_capabilities(self) -> None: - self.register( - self._builtin_descriptor("db.get", "Read plugin kv"), - call_handler=self._db_get, - ) - self.register( - self._builtin_descriptor("db.set", "Write plugin kv"), - call_handler=self._db_set, - ) - self.register( - self._builtin_descriptor("db.delete", "Delete plugin kv"), - call_handler=self._db_delete, - ) - self.register( - self._builtin_descriptor("db.list", "List plugin kv"), - call_handler=self._db_list, - ) - self.register( - self._builtin_descriptor("db.get_many", "Read plugin kv in batch"), - call_handler=self._db_get_many, - ) - self.register( - self._builtin_descriptor("db.set_many", "Write plugin kv in batch"), - call_handler=self._db_set_many, - ) - self.register( - self._builtin_descriptor( - "db.watch", - "Watch plugin kv", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._db_watch, - ) - - async def _db_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "value": await _get_runtime_sp().get_async( - "plugin", - plugin_id, - str(payload.get("key", "")), - None, - ) - } - - async def _db_set( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - await _get_runtime_sp().put_async( - "plugin", - plugin_id, - str(payload.get("key", "")), - payload.get("value"), - ) - return {} - - async def _db_delete( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - await _get_runtime_sp().remove_async( - "plugin", - plugin_id, - str(payload.get("key", "")), - ) - return {} - - async def _db_list( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - prefix = payload.get("prefix") - prefix_value = str(prefix) if isinstance(prefix, str) else None - items = await _get_runtime_sp().range_get_async("plugin", plugin_id, None) - keys = sorted( - item.key - for item in items - if prefix_value is None or item.key.startswith(prefix_value) - ) - return {"keys": keys} - - async def _db_get_many( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - keys_payload = payload.get("keys") - if not isinstance(keys_payload, list): - raise AstrBotError.invalid_input("db.get_many requires a keys array") - items = [] - for key in keys_payload: - key_text = str(key) - items.append( - { - "key": key_text, - "value": await _get_runtime_sp().get_async( - "plugin", - plugin_id, - key_text, - None, - ), - } - ) - return {"items": items} - - async def _db_set_many( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - items_payload = payload.get("items") - if not isinstance(items_payload, list): - raise AstrBotError.invalid_input("db.set_many requires an items array") - for item in items_payload: - if not isinstance(item, dict): - raise AstrBotError.invalid_input("db.set_many items must be objects") - await _get_runtime_sp().put_async( - "plugin", - plugin_id, - str(item.get("key", "")), - item.get("value"), - ) - return {} - - async def _db_watch( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> StreamExecution: - raise AstrBotError.invalid_input( - "db.watch is unsupported in AstrBot SDK MVP", - hint="Use db.get/list polling in MVP", - ) - - def _register_memory_capabilities(self) -> None: - self.register( - self._builtin_descriptor("memory.search", "Search plugin memory"), - call_handler=self._memory_search, - ) - self.register( - self._builtin_descriptor("memory.save", "Save plugin memory"), - call_handler=self._memory_save, - ) - self.register( - self._builtin_descriptor("memory.get", "Get plugin memory"), - call_handler=self._memory_get, - ) - self.register( - self._builtin_descriptor("memory.delete", "Delete plugin memory"), - call_handler=self._memory_delete, - ) - self.register( - self._builtin_descriptor( - "memory.save_with_ttl", - "Save plugin memory with ttl metadata", - ), - call_handler=self._memory_save_with_ttl, - ) - self.register( - self._builtin_descriptor("memory.get_many", "Get plugin memories"), - call_handler=self._memory_get_many, - ) - self.register( - self._builtin_descriptor("memory.delete_many", "Delete plugin memories"), - call_handler=self._memory_delete_many, - ) - self.register( - self._builtin_descriptor("memory.stats", "Get plugin memory stats"), - call_handler=self._memory_stats, - ) - - async def _memory_search( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - query = str(payload.get("query", "")) - mode = str(payload.get("mode", "auto")).strip().lower() or "auto" - limit = self._optional_int(payload.get("limit")) - raw_min_score = payload.get("min_score") - min_score = float(raw_min_score) if raw_min_score is not None else None - namespace = str(payload.get("namespace")) if payload.get("namespace") else None - include_descendants = bool(payload.get("include_descendants", True)) - provider_id = self._resolve_memory_embedding_provider_id( - payload, - required=mode in {"vector", "hybrid"}, - ) - effective_mode = mode - if effective_mode == "auto": - effective_mode = "hybrid" if provider_id is not None else "keyword" - backend = self._memory_backend_for_plugin(plugin_id) - items = await backend.search( - query, - namespace=namespace, - include_descendants=include_descendants, - mode=effective_mode, - limit=limit, - min_score=min_score, - provider_id=provider_id, - embed_one=( - ( - lambda text: self._memory_embedding_for_text( - request_id, - provider_id, - text, - _token, - ) - ) - if provider_id is not None and effective_mode in {"vector", "hybrid"} - else None - ), - embed_many=( - ( - lambda texts: self._memory_embeddings_for_texts( - request_id, - provider_id, - texts, - _token, - ) - ) - if provider_id is not None and effective_mode in {"vector", "hybrid"} - else None - ), - ) - return {"items": items} - - async def _memory_embedding_for_text( - self, - request_id: str, - provider_id: str, - text: str, - token, - ) -> list[float]: - output = await self._provider_embedding_get_embedding( - request_id, - {"provider_id": provider_id, "text": text}, - token, - ) - embedding = output.get("embedding") - if not isinstance(embedding, list): - return [] - return [float(item) for item in embedding] - - async def _memory_embeddings_for_texts( - self, - request_id: str, - provider_id: str, - texts: list[str], - token, - ) -> list[list[float]]: - output = await self._provider_embedding_get_embeddings( - request_id, - {"provider_id": provider_id, "texts": texts}, - token, - ) - embeddings = output.get("embeddings") - if not isinstance(embeddings, list): - return [] - return [ - [float(value) for value in item] - for item in embeddings - if isinstance(item, list) - ] - - async def _memory_save( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - value = payload.get("value") - if not isinstance(value, dict): - raise AstrBotError.invalid_input("memory.save requires an object value") - await self._memory_backend_for_plugin(plugin_id).save( - str(payload.get("key", "")), - value, - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - value = await self._memory_backend_for_plugin(plugin_id).get( - str(payload.get("key", "")), - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"value": value} - - async def _memory_delete( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - await self._memory_backend_for_plugin(plugin_id).delete( - str(payload.get("key", "")), - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_save_with_ttl( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - value = payload.get("value") - if not isinstance(value, dict): - raise AstrBotError.invalid_input( - "memory.save_with_ttl requires an object value" - ) - ttl_seconds = int(payload.get("ttl_seconds", 0)) - await self._memory_backend_for_plugin(plugin_id).save_with_ttl( - str(payload.get("key", "")), - value, - ttl_seconds, - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {} - - async def _memory_get_many( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - keys_payload = payload.get("keys") - if not isinstance(keys_payload, list): - raise AstrBotError.invalid_input("memory.get_many requires a keys array") - items = await self._memory_backend_for_plugin(plugin_id).get_many( - [str(key) for key in keys_payload], - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"items": items} - - async def _memory_delete_many( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - keys_payload = payload.get("keys") - if not isinstance(keys_payload, list): - raise AstrBotError.invalid_input("memory.delete_many requires a keys array") - deleted_count = await self._memory_backend_for_plugin(plugin_id).delete_many( - [str(key) for key in keys_payload], - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - ) - return {"deleted_count": deleted_count} - - async def _memory_stats( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - stats = await self._memory_backend_for_plugin(plugin_id).stats( - namespace=( - str(payload.get("namespace")) - if payload.get("namespace") is not None - else None - ), - include_descendants=bool(payload.get("include_descendants", True)), - ) - stats["plugin_id"] = plugin_id - return stats - - def _register_http_capabilities(self) -> None: - self.register( - self._builtin_descriptor("http.register_api", "Register http route"), - call_handler=self._http_register_api, - ) - self.register( - self._builtin_descriptor("http.unregister_api", "Unregister http route"), - call_handler=self._http_unregister_api, - ) - self.register( - self._builtin_descriptor("http.list_apis", "List http routes"), - call_handler=self._http_list_apis, - ) - - async def _http_register_api( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - methods = payload.get("methods") - if not isinstance(methods, list) or not all( - isinstance(item, str) for item in methods - ): - raise AstrBotError.invalid_input( - "http.register_api requires a string methods array" - ) - self._plugin_bridge.register_http_api( - plugin_id=plugin_id, - route=str(payload.get("route", "")), - methods=methods, - handler_capability=str(payload.get("handler_capability", "")), - description=str(payload.get("description", "")), - ) - return {} - - async def _http_unregister_api( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - methods = payload.get("methods") - if not isinstance(methods, list) or not all( - isinstance(item, str) for item in methods - ): - raise AstrBotError.invalid_input( - "http.unregister_api requires a string methods array" - ) - self._plugin_bridge.unregister_http_api( - plugin_id=plugin_id, - route=str(payload.get("route", "")), - methods=methods, - ) - return {} - - async def _http_list_apis( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return {"apis": self._plugin_bridge.list_http_apis(plugin_id)} - - def _register_metadata_capabilities(self) -> None: - self.register( - self._builtin_descriptor("metadata.get_plugin", "Get plugin metadata"), - call_handler=self._metadata_get_plugin, - ) - self.register( - self._builtin_descriptor("metadata.list_plugins", "List plugins metadata"), - call_handler=self._metadata_list_plugins, - ) - self.register( - self._builtin_descriptor( - "metadata.get_plugin_config", - "Get current plugin config", - ), - call_handler=self._metadata_get_plugin_config, - ) - self.register( - self._builtin_descriptor( - "metadata.save_plugin_config", - "Save current plugin config", - ), - call_handler=self._metadata_save_plugin_config, - ) - - async def _metadata_get_plugin( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin = self._plugin_bridge.get_plugin_metadata(str(payload.get("name", ""))) - return {"plugin": plugin} - - async def _metadata_list_plugins( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return {"plugins": self._plugin_bridge.list_plugin_metadata()} - - async def _metadata_get_plugin_config( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - requested = str(payload.get("name", "")) - if requested != plugin_id: - return {"config": None} - return {"config": self._plugin_bridge.get_plugin_config(plugin_id)} - - async def _metadata_save_plugin_config( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - config = payload.get("config") - if not isinstance(config, dict): - raise AstrBotError.invalid_input( - "metadata.save_plugin_config requires config object" - ) - return {"config": self._plugin_bridge.save_plugin_config(plugin_id, config)} diff --git a/astrbot/core/sdk_bridge/capabilities/conversation.py b/astrbot/core/sdk_bridge/capabilities/conversation.py deleted file mode 100644 index 90ba6a15f..000000000 --- a/astrbot/core/sdk_bridge/capabilities/conversation.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -from astrbot_sdk.errors import AstrBotError - -from ._host import CapabilityMixinHost - - -class ConversationCapabilityMixin(CapabilityMixinHost): - def _register_conversation_capabilities(self) -> None: - self.register( - self._builtin_descriptor("conversation.new", "Create conversation"), - call_handler=self._conversation_new, - ) - self.register( - self._builtin_descriptor("conversation.switch", "Switch conversation"), - call_handler=self._conversation_switch, - ) - self.register( - self._builtin_descriptor("conversation.delete", "Delete conversation"), - call_handler=self._conversation_delete, - ) - self.register( - self._builtin_descriptor("conversation.get", "Get conversation"), - call_handler=self._conversation_get, - ) - self.register( - self._builtin_descriptor( - "conversation.get_current", - "Get current conversation", - ), - call_handler=self._conversation_get_current, - ) - self.register( - self._builtin_descriptor("conversation.list", "List conversations"), - call_handler=self._conversation_list, - ) - self.register( - self._builtin_descriptor("conversation.update", "Update conversation"), - call_handler=self._conversation_update, - ) - self.register( - self._builtin_descriptor( - "conversation.unset_persona", - "Unset conversation persona override", - ), - call_handler=self._conversation_unset_persona, - ) - - async def _conversation_new( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = str(payload.get("session", "")).strip() - if not session: - raise AstrBotError.invalid_input("conversation.new requires session") - raw_conversation = payload.get("conversation") - if raw_conversation is None: - raw_conversation = {} - if not isinstance(raw_conversation, dict): - raise AstrBotError.invalid_input( - "conversation.new requires conversation object" - ) - conversation_id = ( - await self._star_context.conversation_manager.new_conversation( - unified_msg_origin=session, - platform_id=( - str(raw_conversation.get("platform_id")) - if raw_conversation.get("platform_id") is not None - else None - ), - content=self._normalize_history_items(raw_conversation.get("history")), - title=( - str(raw_conversation.get("title")) - if raw_conversation.get("title") is not None - else None - ), - persona_id=( - str(raw_conversation.get("persona_id")) - if raw_conversation.get("persona_id") is not None - else None - ), - ) - ) - return {"conversation_id": conversation_id} - - async def _conversation_switch( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = str(payload.get("session", "")).strip() - conversation_id = str(payload.get("conversation_id", "")).strip() - if not session: - raise AstrBotError.invalid_input("conversation.switch requires session") - if not conversation_id: - raise AstrBotError.invalid_input( - "conversation.switch requires conversation_id" - ) - await self._star_context.conversation_manager.switch_conversation( - unified_msg_origin=session, - conversation_id=conversation_id, - ) - return {} - - async def _conversation_delete( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - await self._star_context.conversation_manager.delete_conversation( - unified_msg_origin=str(payload.get("session", "")), - conversation_id=( - str(payload.get("conversation_id")) - if payload.get("conversation_id") is not None - else None - ), - ) - return {} - - async def _conversation_get( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - conversation = await self._star_context.conversation_manager.get_conversation( - unified_msg_origin=str(payload.get("session", "")), - conversation_id=str(payload.get("conversation_id", "")), - create_if_not_exists=bool(payload.get("create_if_not_exists", False)), - ) - return {"conversation": self._serialize_conversation(conversation)} - - async def _conversation_get_current( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = str(payload.get("session", "")) - conversation_id = ( - await self._star_context.conversation_manager.get_curr_conversation_id( - session - ) - ) - if not conversation_id and bool(payload.get("create_if_not_exists", False)): - conversation_id = ( - await self._star_context.conversation_manager.new_conversation(session) - ) - if not conversation_id: - return {"conversation": None} - conversation = await self._star_context.conversation_manager.get_conversation( - unified_msg_origin=session, - conversation_id=conversation_id, - create_if_not_exists=bool(payload.get("create_if_not_exists", False)), - ) - return {"conversation": self._serialize_conversation(conversation)} - - async def _conversation_list( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = payload.get("session") - platform_id = payload.get("platform_id") - conversations = await self._star_context.conversation_manager.get_conversations( - unified_msg_origin=( - str(session) if session is not None and str(session).strip() else None - ), - platform_id=( - str(platform_id) - if platform_id is not None and str(platform_id).strip() - else None - ), - ) - return { - "conversations": [ - item - for item in ( - self._serialize_conversation(conversation) - for conversation in conversations - ) - if item is not None - ] - } - - async def _conversation_update( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - raw_conversation = payload.get("conversation") - if raw_conversation is None: - raw_conversation = {} - if not isinstance(raw_conversation, dict): - raise AstrBotError.invalid_input( - "conversation.update requires conversation object" - ) - await self._star_context.conversation_manager.update_conversation( - unified_msg_origin=str(payload.get("session", "")), - conversation_id=( - str(payload.get("conversation_id")) - if payload.get("conversation_id") is not None - else None - ), - history=( - self._normalize_history_items(raw_conversation.get("history")) - if "history" in raw_conversation - else None - ), - title=( - str(raw_conversation.get("title")) - if raw_conversation.get("title") is not None - else None - ), - persona_id=( - str(raw_conversation.get("persona_id")) - if raw_conversation.get("persona_id") is not None - else None - ), - token_usage=self._optional_int(raw_conversation.get("token_usage")), - ) - return {} - - async def _conversation_unset_persona( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - await self._star_context.conversation_manager.unset_conversation_persona( - unified_msg_origin=str(payload.get("session", "")), - conversation_id=( - str(payload.get("conversation_id")) - if payload.get("conversation_id") is not None - else None - ), - ) - return {} diff --git a/astrbot/core/sdk_bridge/capabilities/kb.py b/astrbot/core/sdk_bridge/capabilities/kb.py deleted file mode 100644 index fe252d414..000000000 --- a/astrbot/core/sdk_bridge/capabilities/kb.py +++ /dev/null @@ -1,456 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -from astrbot_sdk.errors import AstrBotError - -from astrbot.core.sdk_bridge.bridge_base import _get_runtime_file_token_service - -from ._host import CapabilityMixinHost - - -class KnowledgeBaseCapabilityMixin(CapabilityMixinHost): - def _register_kb_capabilities(self) -> None: - self.register( - self._builtin_descriptor("kb.list", "List knowledge bases"), - call_handler=self._kb_list, - ) - self.register( - self._builtin_descriptor("kb.get", "Get knowledge base"), - call_handler=self._kb_get, - ) - self.register( - self._builtin_descriptor("kb.create", "Create knowledge base"), - call_handler=self._kb_create, - ) - self.register( - self._builtin_descriptor("kb.update", "Update knowledge base"), - call_handler=self._kb_update, - ) - self.register( - self._builtin_descriptor("kb.delete", "Delete knowledge base"), - call_handler=self._kb_delete, - ) - self.register( - self._builtin_descriptor("kb.retrieve", "Retrieve from knowledge bases"), - call_handler=self._kb_retrieve, - ) - self.register( - self._builtin_descriptor( - "kb.document.upload", "Upload knowledge base document" - ), - call_handler=self._kb_document_upload, - ) - self.register( - self._builtin_descriptor( - "kb.document.list", "List knowledge base documents" - ), - call_handler=self._kb_document_list, - ) - self.register( - self._builtin_descriptor("kb.document.get", "Get knowledge base document"), - call_handler=self._kb_document_get, - ) - self.register( - self._builtin_descriptor( - "kb.document.delete", - "Delete knowledge base document", - ), - call_handler=self._kb_document_delete, - ) - self.register( - self._builtin_descriptor( - "kb.document.refresh", - "Refresh knowledge base document", - ), - call_handler=self._kb_document_refresh, - ) - - async def _get_kb_helper(self, kb_id: str): - return await self._star_context.kb_manager.get_kb(kb_id) - - async def _require_kb_helper(self, kb_id: str): - kb_id_text = str(kb_id).strip() - if not kb_id_text: - raise AstrBotError.invalid_input("kb capability requires kb_id") - kb_helper = await self._get_kb_helper(kb_id_text) - if kb_helper is None: - raise AstrBotError.invalid_input(f"Unknown knowledge base: {kb_id_text}") - return kb_helper - - @staticmethod - def _normalize_kb_names(payload: dict[str, Any]) -> list[str]: - raw_names = payload.get("kb_names") - if not isinstance(raw_names, list): - return [] - return [str(item).strip() for item in raw_names if str(item).strip()] - - @staticmethod - def _normalize_kb_ids(payload: dict[str, Any]) -> list[str]: - raw_ids = payload.get("kb_ids") - if not isinstance(raw_ids, list): - return [] - return [str(item).strip() for item in raw_ids if str(item).strip()] - - async def _resolve_retrieve_kb_names( - self, - payload: dict[str, Any], - ) -> list[str]: - kb_names = self._normalize_kb_names(payload) - if kb_names: - return kb_names - resolved_names: list[str] = [] - for kb_id in self._normalize_kb_ids(payload): - kb_helper = await self._get_kb_helper(kb_id) - if kb_helper is not None and getattr(kb_helper, "kb", None) is not None: - kb_name = str(getattr(kb_helper.kb, "kb_name", "")).strip() - if kb_name: - resolved_names.append(kb_name) - return resolved_names - - async def _kb_list( - self, - _request_id: str, - _payload: dict[str, object], - _token, - ) -> dict[str, object]: - kbs = await self._star_context.kb_manager.list_kbs() - return { - "kbs": [ - payload - for payload in (self._serialize_kb(kb) for kb in kbs) - if payload is not None - ] - } - - async def _kb_get( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_helper = await self._get_kb_helper(str(payload.get("kb_id", ""))) - return {"kb": self._serialize_kb(kb_helper)} - - async def _kb_create( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - raw_kb = payload.get("kb") - if not isinstance(raw_kb, dict): - raise AstrBotError.invalid_input("kb.create requires kb object") - try: - kb_helper = await self._star_context.kb_manager.create_kb( - kb_name=str(raw_kb.get("kb_name", "")), - description=( - str(raw_kb.get("description")) - if raw_kb.get("description") is not None - else None - ), - emoji=( - str(raw_kb.get("emoji")) - if raw_kb.get("emoji") is not None - else None - ), - embedding_provider_id=( - str(raw_kb.get("embedding_provider_id")) - if raw_kb.get("embedding_provider_id") is not None - else None - ), - rerank_provider_id=( - str(raw_kb.get("rerank_provider_id")) - if raw_kb.get("rerank_provider_id") is not None - else None - ), - chunk_size=self._optional_int(raw_kb.get("chunk_size")), - chunk_overlap=self._optional_int(raw_kb.get("chunk_overlap")), - top_k_dense=self._optional_int(raw_kb.get("top_k_dense")), - top_k_sparse=self._optional_int(raw_kb.get("top_k_sparse")), - top_m_final=self._optional_int(raw_kb.get("top_m_final")), - ) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"kb": self._serialize_kb(kb_helper)} - - async def _kb_update( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_id = str(payload.get("kb_id", "")).strip() - raw_kb = payload.get("kb") - if not isinstance(raw_kb, dict): - raise AstrBotError.invalid_input("kb.update requires kb object") - kb_helper = await self._get_kb_helper(kb_id) - if kb_helper is None: - return {"kb": None} - current_kb = getattr(kb_helper, "kb", None) - kb_name = raw_kb.get("kb_name") - try: - updated_helper = await self._star_context.kb_manager.update_kb( - kb_id=kb_id, - kb_name=( - str(kb_name) - if kb_name is not None - else str(getattr(current_kb, "kb_name", "")) - ), - description=( - str(raw_kb.get("description")) - if raw_kb.get("description") is not None - else None - ) - if "description" in raw_kb - else None, - emoji=( - str(raw_kb.get("emoji")) - if raw_kb.get("emoji") is not None - else None - ) - if "emoji" in raw_kb - else None, - embedding_provider_id=( - str(raw_kb.get("embedding_provider_id")) - if raw_kb.get("embedding_provider_id") is not None - else None - ) - if "embedding_provider_id" in raw_kb - else None, - rerank_provider_id=( - str(raw_kb.get("rerank_provider_id")) - if raw_kb.get("rerank_provider_id") is not None - else None - ) - if "rerank_provider_id" in raw_kb - else None, - chunk_size=( - self._optional_int(raw_kb.get("chunk_size")) - if "chunk_size" in raw_kb - else None - ), - chunk_overlap=( - self._optional_int(raw_kb.get("chunk_overlap")) - if "chunk_overlap" in raw_kb - else None - ), - top_k_dense=( - self._optional_int(raw_kb.get("top_k_dense")) - if "top_k_dense" in raw_kb - else None - ), - top_k_sparse=( - self._optional_int(raw_kb.get("top_k_sparse")) - if "top_k_sparse" in raw_kb - else None - ), - top_m_final=( - self._optional_int(raw_kb.get("top_m_final")) - if "top_m_final" in raw_kb - else None - ), - ) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"kb": self._serialize_kb(updated_helper)} - - async def _kb_delete( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - deleted = await self._star_context.kb_manager.delete_kb( - str(payload.get("kb_id", "")) - ) - return {"deleted": bool(deleted)} - - async def _kb_retrieve( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - query = str(payload.get("query", "")).strip() - if not query: - raise AstrBotError.invalid_input("kb.retrieve requires query") - kb_names = await self._resolve_retrieve_kb_names(payload) - if not kb_names: - raise AstrBotError.invalid_input("kb.retrieve requires kb_ids or kb_names") - result = await self._star_context.kb_manager.retrieve( - query=query, - kb_names=kb_names, - top_k_fusion=self._optional_int(payload.get("top_k_fusion")) or 20, - top_m_final=self._optional_int(payload.get("top_m_final")) or 5, - ) - if result is None: - return {"result": None} - return {"result": dict(result)} - - async def _kb_document_upload( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_id = str(payload.get("kb_id", "")).strip() - kb_helper = await self._require_kb_helper(kb_id) - raw_document = payload.get("document") - if not isinstance(raw_document, dict): - raise AstrBotError.invalid_input( - "kb.document.upload requires document object" - ) - - text_value = raw_document.get("text") - if isinstance(text_value, str) and text_value.strip(): - file_name = str(raw_document.get("file_name", "")).strip() or "document.txt" - file_type = ( - str(raw_document.get("file_type", "")).strip() - or Path(file_name).suffix.lstrip(".") - or "txt" - ) - document = await kb_helper.upload_document( - file_name=file_name, - file_content=None, - file_type=file_type, - chunk_size=self._optional_int(raw_document.get("chunk_size")) or 512, - chunk_overlap=( - self._optional_int(raw_document.get("chunk_overlap")) or 50 - ), - batch_size=self._optional_int(raw_document.get("batch_size")) or 32, - tasks_limit=self._optional_int(raw_document.get("tasks_limit")) or 3, - max_retries=self._optional_int(raw_document.get("max_retries")) or 3, - pre_chunked_text=[text_value], - ) - return {"document": self._serialize_kb_document(document)} - - url_value = raw_document.get("url") - if isinstance(url_value, str) and url_value.strip(): - try: - document = await self._star_context.kb_manager.upload_from_url( - kb_id=kb_id, - url=url_value.strip(), - chunk_size=self._optional_int(raw_document.get("chunk_size")) - or 512, - chunk_overlap=( - self._optional_int(raw_document.get("chunk_overlap")) or 50 - ), - batch_size=self._optional_int(raw_document.get("batch_size")) or 32, - tasks_limit=self._optional_int(raw_document.get("tasks_limit")) - or 3, - max_retries=self._optional_int(raw_document.get("max_retries")) - or 3, - enable_cleaning=bool(raw_document.get("enable_cleaning", False)), - cleaning_provider_id=( - str(raw_document.get("cleaning_provider_id")) - if raw_document.get("cleaning_provider_id") is not None - else None - ), - ) - except (OSError, ValueError) as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"document": self._serialize_kb_document(document)} - - file_token = str(raw_document.get("file_token", "")).strip() - if not file_token: - raise AstrBotError.invalid_input( - "kb.document.upload requires file_token, url, or text" - ) - try: - file_path = await _get_runtime_file_token_service().handle_file(file_token) - except KeyError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - path = Path(file_path) - if not path.exists(): - raise AstrBotError.invalid_input(f"File does not exist: {file_path}") - file_name = str(raw_document.get("file_name", "")).strip() or path.name - file_type = str( - raw_document.get("file_type", "") - ).strip() or path.suffix.lstrip(".") - if not file_type: - raise AstrBotError.invalid_input( - "kb.document.upload requires file_type when the file has no suffix" - ) - file_content = await asyncio.to_thread(path.read_bytes) - try: - document = await kb_helper.upload_document( - file_name=file_name, - file_content=file_content, - file_type=file_type, - chunk_size=self._optional_int(raw_document.get("chunk_size")) or 512, - chunk_overlap=( - self._optional_int(raw_document.get("chunk_overlap")) or 50 - ), - batch_size=self._optional_int(raw_document.get("batch_size")) or 32, - tasks_limit=self._optional_int(raw_document.get("tasks_limit")) or 3, - max_retries=self._optional_int(raw_document.get("max_retries")) or 3, - ) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"document": self._serialize_kb_document(document)} - - async def _kb_document_list( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_helper = await self._require_kb_helper(str(payload.get("kb_id", ""))) - documents = await kb_helper.list_documents( - offset=self._optional_int(payload.get("offset")) or 0, - limit=self._optional_int(payload.get("limit")) or 100, - ) - return { - "documents": [ - item - for item in ( - self._serialize_kb_document(document) for document in documents - ) - if item is not None - ] - } - - async def _kb_document_get( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_helper = await self._require_kb_helper(str(payload.get("kb_id", ""))) - document = await kb_helper.get_document(str(payload.get("doc_id", ""))) - return {"document": self._serialize_kb_document(document)} - - async def _kb_document_delete( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_helper = await self._require_kb_helper(str(payload.get("kb_id", ""))) - doc_id = str(payload.get("doc_id", "")).strip() - existing_document = await kb_helper.get_document(doc_id) - if existing_document is None: - return {"deleted": False} - await kb_helper.delete_document(doc_id) - return {"deleted": True} - - async def _kb_document_refresh( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - kb_helper = await self._require_kb_helper(str(payload.get("kb_id", ""))) - doc_id = str(payload.get("doc_id", "")).strip() - document = await kb_helper.get_document(doc_id) - if document is None: - return {"document": None} - try: - await kb_helper.refresh_document(doc_id) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - refreshed_document = await kb_helper.get_document(doc_id) - return {"document": self._serialize_kb_document(refreshed_document)} diff --git a/astrbot/core/sdk_bridge/capabilities/llm.py b/astrbot/core/sdk_bridge/capabilities/llm.py deleted file mode 100644 index c5bd47fb8..000000000 --- a/astrbot/core/sdk_bridge/capabilities/llm.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import annotations - -import asyncio -import time -from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, Protocol, TypeGuard - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.runtime.capability_router import StreamExecution - -from astrbot import logger - -from ..bridge_base import _get_runtime_tool_types -from ._host import CapabilityMixinHost - -if TYPE_CHECKING: - from astrbot.core.agent.tool import ToolSet - from astrbot.core.provider.entities import LLMResponse - - -class _ChatProvider(Protocol): - async def text_chat(self, **kwargs: Any) -> LLMResponse: ... - - async def text_chat_stream(self, **kwargs: Any) -> AsyncIterator[LLMResponse]: ... - - -class _ProviderMetaLike(Protocol): - id: str - model: str | None - - -class LLMCapabilityMixin(CapabilityMixinHost): - def _register_llm_capabilities(self) -> None: - self.register( - self._builtin_descriptor("llm.chat", "Send chat request"), - call_handler=self._llm_chat, - ) - self.register( - self._builtin_descriptor( - "llm.chat_raw", - "Send chat request and return raw response", - ), - call_handler=self._llm_chat_raw, - ) - self.register( - self._builtin_descriptor( - "llm.stream_chat", - "Stream chat response", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._llm_stream_chat, - ) - - async def _llm_chat( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - response = await self._call_llm(payload, request_id=request_id) - return {"text": response.completion_text} - - async def _llm_chat_raw( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - response = await self._call_llm(payload, request_id=request_id) - usage = None - if response.usage is not None: - usage = { - "input_tokens": response.usage.input, - "output_tokens": response.usage.output, - "total_tokens": response.usage.total, - } - return { - "text": response.completion_text, - "usage": usage, - "finish_reason": "tool_calls" if response.tools_call_ids else "stop", - "tool_calls": response.to_openai_tool_calls(), - "role": response.role, - "reasoning_content": response.reasoning_content or None, - "reasoning_signature": response.reasoning_signature, - } - - async def _llm_stream_chat( - self, - request_id: str, - payload: dict[str, Any], - token, - ) -> StreamExecution: - provider, request_kwargs = self._resolve_llm_request( - payload, - request_id=request_id, - ) - started_at = time.perf_counter() - provider_label = self._describe_provider(provider) - - async def fallback_iterator() -> AsyncIterator[dict[str, Any]]: - logger.warning( - f"SDK llm.stream_chat fell back to non-streaming provider.text_chat for {provider_label}" - ) - response = await provider.text_chat(**request_kwargs) - logger.info( - f"SDK llm.stream_chat fallback first output for {provider_label} after {time.perf_counter() - started_at:.3f}s" - ) - for char in response.completion_text: - token.raise_if_cancelled() - await asyncio.sleep(0) - yield {"text": char} - - async def iterator() -> AsyncIterator[dict[str, Any]]: - try: - stream = provider.text_chat_stream(**request_kwargs) - yielded_text = False - first_text_logged = False - async for response in stream: - token.raise_if_cancelled() - text = response.completion_text - if response.is_chunk: - if text: - if not first_text_logged: - first_text_logged = True - logger.info( - f"SDK llm.stream_chat first streamed chunk for {provider_label} after {time.perf_counter() - started_at:.3f}s" - ) - yielded_text = True - yield {"text": text} - continue - if text: - if not first_text_logged: - first_text_logged = True - logger.info( - f"SDK llm.stream_chat first final chunk for {provider_label} after {time.perf_counter() - started_at:.3f}s" - ) - if yielded_text: - yield {"_final_text": text} - else: - yielded_text = True - yield {"text": text, "_final_text": text} - else: - yield {"_final_text": text} - except NotImplementedError: - async for item in fallback_iterator(): - yield item - - def finalize(chunks: list[dict[str, Any]]) -> dict[str, Any]: - final_text = None - for item in reversed(chunks): - if "_final_text" in item: - final_text = str(item.get("_final_text", "")) - break - if final_text is None: - final_text = "".join(str(item.get("text", "")) for item in chunks) - return {"text": final_text} - - return StreamExecution( - iterator=iterator(), - finalize=finalize, - ) - - async def _call_llm( - self, - payload: dict[str, Any], - *, - request_id: str, - ) -> LLMResponse: - provider, request_kwargs = self._resolve_llm_request( - payload, - request_id=request_id, - ) - return await provider.text_chat(**request_kwargs) - - def _resolve_llm_request( - self, - payload: dict[str, Any], - *, - request_id: str, - ) -> tuple[_ChatProvider, dict[str, Any]]: - request_context = self._plugin_bridge.resolve_request_session(request_id) - provider_id = payload.get("provider_id") - if provider_id: - provider = self._star_context.get_provider_by_id(str(provider_id)) - else: - request_context_has_event = False - if request_context is not None: - has_event = getattr(request_context, "has_event", None) - request_context_has_event = ( - bool(has_event) - if has_event is not None - else hasattr(request_context, "event") - ) - provider = self._star_context.get_using_provider( - request_context.event.unified_msg_origin - if request_context is not None and request_context_has_event - else None, - ) - if provider is None: - raise AstrBotError.internal_error( - "No active chat provider is available", - hint="Please configure a chat provider in AstrBot first", - ) - if not self._is_chat_provider(provider): - raise AstrBotError.invalid_input( - f"Provider '{provider_id}' is not a chat provider", - hint="Please choose a configured chat provider for llm.chat requests", - ) - return provider, self._normalize_llm_payload(payload) - - @staticmethod - def _describe_provider(provider: _ChatProvider) -> str: - provider_meta_getter = getattr(provider, "meta", None) - if not callable(provider_meta_getter): - return provider.__class__.__name__ - provider_meta = provider_meta_getter() - if not LLMCapabilityMixin._is_provider_meta(provider_meta): - return provider.__class__.__name__ - return f"{provider_meta.id}/{provider_meta.model}" - - @staticmethod - def _is_chat_provider(provider: object) -> TypeGuard[_ChatProvider]: - return callable(getattr(provider, "text_chat", None)) and callable( - getattr(provider, "text_chat_stream", None) - ) - - @staticmethod - def _is_provider_meta(value: object) -> TypeGuard[_ProviderMetaLike]: - return hasattr(value, "id") and hasattr(value, "model") - - @staticmethod - def _normalize_llm_payload(payload: dict[str, Any]) -> dict[str, Any]: - contexts_payload = payload.get("contexts") - if contexts_payload is None: - contexts_payload = payload.get("history") - contexts = ( - [dict(item) for item in contexts_payload] - if isinstance(contexts_payload, list) - else None - ) - image_urls = payload.get("image_urls") - tool_calls_result = payload.get("tool_calls_result") - tools_payload = payload.get("tools") - request_kwargs: dict[str, Any] = { - "prompt": str(payload.get("prompt", "")), - "image_urls": ( - [str(item) for item in image_urls] - if isinstance(image_urls, list) - else None - ), - "func_tool": ( - LLMCapabilityMixin._build_toolset(tools_payload) - if isinstance(tools_payload, list) - else None - ), - "contexts": contexts, - "tool_calls_result": ( - [dict(item) for item in tool_calls_result] - if isinstance(tool_calls_result, list) - else None - ), - "system_prompt": str(payload.get("system", "")), - "model": (str(payload["model"]) if payload.get("model") else None), - "temperature": payload.get("temperature"), - } - return request_kwargs - - @staticmethod - def _build_toolset(tools_payload: list[Any]) -> ToolSet: - function_tool_cls, tool_set_cls = _get_runtime_tool_types() - tool_set = tool_set_cls() - for item in tools_payload: - if not isinstance(item, dict): - raise AstrBotError.invalid_input("llm tools items must be objects") - if str(item.get("type", "function")) != "function": - raise AstrBotError.invalid_input( - "Only function tools are supported in AstrBot SDK MVP" - ) - function_payload = item.get("function") - if not isinstance(function_payload, dict): - raise AstrBotError.invalid_input( - "llm tools items must contain a function object" - ) - name = str(function_payload.get("name", "")).strip() - if not name: - raise AstrBotError.invalid_input( - "llm function tool name must not be empty" - ) - description = str(function_payload.get("description", "") or "") - parameters = function_payload.get("parameters") - if not isinstance(parameters, dict): - parameters = {"type": "object", "properties": {}} - tool_set.add_tool( - function_tool_cls( - name=name, - description=description, - parameters=parameters, - handler=None, - ) - ) - return tool_set diff --git a/astrbot/core/sdk_bridge/capabilities/mcp.py b/astrbot/core/sdk_bridge/capabilities/mcp.py deleted file mode 100644 index ff58c83b5..000000000 --- a/astrbot/core/sdk_bridge/capabilities/mcp.py +++ /dev/null @@ -1,517 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from astrbot_sdk.errors import AstrBotError - -from astrbot.core import logger - -from ._host import CapabilityMixinHost - - -class MCPCapabilityMixin(CapabilityMixinHost): - @staticmethod - def _mcp_timeout(payload: dict[str, Any], capability_name: str) -> float: - raw_timeout = payload.get("timeout", 30.0) - try: - timeout = float(raw_timeout) - except (TypeError, ValueError) as exc: - raise AstrBotError.invalid_input( - f"{capability_name} requires numeric timeout" - ) from exc - if timeout <= 0: - raise AstrBotError.invalid_input(f"{capability_name} requires timeout > 0") - return timeout - - @staticmethod - def _mcp_name(payload: dict[str, Any], capability_name: str) -> str: - name = str(payload.get("name", "")).strip() - if not name: - raise AstrBotError.invalid_input(f"{capability_name} requires name") - return name - - @staticmethod - def _mcp_config(payload: dict[str, Any], capability_name: str) -> dict[str, Any]: - config = payload.get("config") - if not isinstance(config, dict): - raise AstrBotError.invalid_input( - f"{capability_name} requires config object" - ) - return dict(config) - - def _func_tool_manager(self): - return self._star_context.get_llm_tool_manager() - - @staticmethod - def _global_mcp_record_from_state( - *, - name: str, - config: dict[str, Any], - runtime: Any | None, - ) -> dict[str, Any]: - client = getattr(runtime, "client", None) if runtime is not None else None - return { - "name": name, - "scope": "global", - "active": bool(config.get("active", True)), - "running": runtime is not None, - "config": dict(config), - "tools": [ - str(tool.name) - for tool in getattr(client, "tools", []) - if getattr(tool, "name", None) - ] - if client is not None - else [], - "errlogs": list(getattr(client, "server_errlogs", [])) - if client is not None - else [], - "last_error": None, - } - - def _get_global_mcp_record(self, name: str) -> dict[str, Any] | None: - func_tool_manager = self._func_tool_manager() - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.get("mcpServers") - if not isinstance(servers, dict): - return None - config = servers.get(name) - if not isinstance(config, dict): - return None - runtime = func_tool_manager.mcp_server_runtime_view.get(name) - return self._global_mcp_record_from_state( - name=name, - config=dict(config), - runtime=runtime, - ) - - def _list_global_mcp_records(self) -> list[dict[str, Any]]: - func_tool_manager = self._func_tool_manager() - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.get("mcpServers") - if not isinstance(servers, dict): - return [] - return [ - self._global_mcp_record_from_state( - name=str(name), - config=dict(config), - runtime=func_tool_manager.mcp_server_runtime_view.get(str(name)), - ) - for name, config in sorted(servers.items(), key=lambda item: str(item[0])) - if str(name).strip() and isinstance(config, dict) - ] - - def _require_global_mcp_ack(self, request_id: str, capability_name: str) -> str: - plugin_id = self._resolve_plugin_id(request_id) - if self._plugin_bridge.acknowledges_global_mcp_risk(plugin_id): - return plugin_id - raise PermissionError( - f"{capability_name} requires @acknowledge_global_mcp_risk" - ) - - @staticmethod - def _audit_global_mcp_mutation( - *, - plugin_id: str, - action: str, - server_name: str, - request_id: str, - ) -> None: - audit_entry = { - "plugin_id": plugin_id, - "action": action, - "server_name": server_name, - "request_id": request_id, - } - logger.info("SDK global MCP mutation: {}", audit_entry) - - async def _mcp_local_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - name = self._mcp_name(payload, "mcp.local.get") - return {"server": self._plugin_bridge.get_local_mcp_server(plugin_id, name)} - - async def _mcp_local_list( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return {"servers": self._plugin_bridge.list_local_mcp_servers(plugin_id)} - - async def _mcp_local_enable( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - name = self._mcp_name(payload, "mcp.local.enable") - timeout = self._mcp_timeout(payload, "mcp.local.enable") - return { - "server": await self._plugin_bridge.enable_local_mcp_server( - plugin_id, - name, - timeout=timeout, - ) - } - - async def _mcp_local_disable( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - name = self._mcp_name(payload, "mcp.local.disable") - return { - "server": await self._plugin_bridge.disable_local_mcp_server( - plugin_id, - name, - ) - } - - async def _mcp_local_wait_until_ready( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - name = self._mcp_name(payload, "mcp.local.wait_until_ready") - timeout = self._mcp_timeout(payload, "mcp.local.wait_until_ready") - return { - "server": await self._plugin_bridge.wait_for_local_mcp_server( - plugin_id, - name, - timeout=timeout, - ) - } - - async def _mcp_session_open( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - name = self._mcp_name(payload, "mcp.session.open") - config = self._mcp_config(payload, "mcp.session.open") - timeout = self._mcp_timeout(payload, "mcp.session.open") - session_id, tools = await self._plugin_bridge.open_temporary_mcp_session( - plugin_id, - name=name, - config=config, - timeout=timeout, - ) - return {"session_id": session_id, "tools": tools} - - async def _mcp_session_list_tools( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - session_id = str(payload.get("session_id", "")).strip() - return { - "tools": self._plugin_bridge.get_temporary_mcp_session_tools( - plugin_id, - session_id, - ) - } - - async def _mcp_session_call_tool( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - session_id = str(payload.get("session_id", "")).strip() - tool_name = str(payload.get("tool_name", "")).strip() - if not tool_name: - raise AstrBotError.invalid_input("mcp.session.call_tool requires tool_name") - args = payload.get("args") - if not isinstance(args, dict): - raise AstrBotError.invalid_input( - "mcp.session.call_tool requires args object" - ) - result = await self._plugin_bridge.call_temporary_mcp_tool( - plugin_id, - session_id=session_id, - tool_name=tool_name, - arguments=dict(args), - ) - return {"result": result} - - async def _mcp_session_close( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - session_id = str(payload.get("session_id", "")).strip() - await self._plugin_bridge.close_temporary_mcp_session(plugin_id, session_id) - return {} - - async def _mcp_global_register( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._require_global_mcp_ack(request_id, "mcp.global.register") - name = self._mcp_name(payload, "mcp.global.register") - config = self._mcp_config(payload, "mcp.global.register") - timeout = self._mcp_timeout(payload, "mcp.global.register") - func_tool_manager = self._func_tool_manager() - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.setdefault("mcpServers", {}) - if not isinstance(servers, dict): - raise AstrBotError.invalid_input("Invalid global MCP config shape") - if name in servers: - raise AstrBotError.invalid_input( - f"Global MCP server already exists: {name}" - ) - normalized_config = dict(config) - normalized_config.setdefault("active", True) - servers[name] = normalized_config - func_tool_manager.save_mcp_config(config_payload) - if bool(normalized_config.get("active", True)): - await func_tool_manager.enable_mcp_server( - name, normalized_config, timeout=timeout - ) - record = self._get_global_mcp_record(name) - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="register", - server_name=name, - request_id=request_id, - ) - return {"server": record} - - async def _mcp_global_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_global_mcp_ack(request_id, "mcp.global.get") - name = self._mcp_name(payload, "mcp.global.get") - return {"server": self._get_global_mcp_record(name)} - - async def _mcp_global_list( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_global_mcp_ack(request_id, "mcp.global.list") - return {"servers": self._list_global_mcp_records()} - - async def _mcp_global_enable( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._require_global_mcp_ack(request_id, "mcp.global.enable") - name = self._mcp_name(payload, "mcp.global.enable") - timeout = self._mcp_timeout(payload, "mcp.global.enable") - func_tool_manager = self._func_tool_manager() - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.get("mcpServers") - if ( - not isinstance(servers, dict) - or name not in servers - or not isinstance(servers[name], dict) - ): - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - servers[name]["active"] = True - func_tool_manager.save_mcp_config(config_payload) - await func_tool_manager.enable_mcp_server( - name, dict(servers[name]), timeout=timeout - ) - record = self._get_global_mcp_record(name) - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="enable", - server_name=name, - request_id=request_id, - ) - return {"server": record} - - async def _mcp_global_disable( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._require_global_mcp_ack(request_id, "mcp.global.disable") - name = self._mcp_name(payload, "mcp.global.disable") - func_tool_manager = self._func_tool_manager() - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.get("mcpServers") - if ( - not isinstance(servers, dict) - or name not in servers - or not isinstance(servers[name], dict) - ): - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - servers[name]["active"] = False - func_tool_manager.save_mcp_config(config_payload) - await func_tool_manager.disable_mcp_server(name) - record = self._get_global_mcp_record(name) - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="disable", - server_name=name, - request_id=request_id, - ) - return {"server": record} - - async def _mcp_global_unregister( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._require_global_mcp_ack(request_id, "mcp.global.unregister") - name = self._mcp_name(payload, "mcp.global.unregister") - func_tool_manager = self._func_tool_manager() - existing_record = self._get_global_mcp_record(name) - if existing_record is None: - raise AstrBotError.invalid_input(f"Unknown global MCP server: {name}") - config_payload = func_tool_manager.load_mcp_config() - servers = config_payload.get("mcpServers") - if not isinstance(servers, dict): - raise AstrBotError.invalid_input("Invalid global MCP config shape") - servers.pop(name, None) - func_tool_manager.save_mcp_config(config_payload) - await func_tool_manager.disable_mcp_server(name) - existing_record["running"] = False - self._audit_global_mcp_mutation( - plugin_id=plugin_id, - action="unregister", - server_name=name, - request_id=request_id, - ) - return {"server": existing_record} - - async def _internal_mcp_local_execute( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = str(payload.get("plugin_id", "")).strip() - server_name = str(payload.get("server_name", "")).strip() - tool_name = str(payload.get("tool_name", "")).strip() - tool_args = payload.get("tool_args") - if not plugin_id or not server_name or not tool_name: - raise AstrBotError.invalid_input( - "internal.mcp.local.execute requires plugin_id, server_name, and tool_name" - ) - if not isinstance(tool_args, dict): - raise AstrBotError.invalid_input( - "internal.mcp.local.execute requires tool_args object" - ) - return await self._plugin_bridge.execute_local_mcp_tool( - plugin_id, - server_name=server_name, - tool_name=tool_name, - tool_args=dict(tool_args), - ) - - def _register_mcp_capabilities(self) -> None: - self.register( - self._builtin_descriptor("mcp.local.get", "Get local MCP server"), - call_handler=self._mcp_local_get, - ) - self.register( - self._builtin_descriptor("mcp.local.list", "List local MCP servers"), - call_handler=self._mcp_local_list, - ) - self.register( - self._builtin_descriptor("mcp.local.enable", "Enable local MCP server"), - call_handler=self._mcp_local_enable, - ) - self.register( - self._builtin_descriptor("mcp.local.disable", "Disable local MCP server"), - call_handler=self._mcp_local_disable, - ) - self.register( - self._builtin_descriptor( - "mcp.local.wait_until_ready", - "Wait until local MCP server is ready", - ), - call_handler=self._mcp_local_wait_until_ready, - ) - self.register( - self._builtin_descriptor("mcp.session.open", "Open temporary MCP session"), - call_handler=self._mcp_session_open, - ) - self.register( - self._builtin_descriptor( - "mcp.session.list_tools", - "List temporary MCP session tools", - ), - call_handler=self._mcp_session_list_tools, - ) - self.register( - self._builtin_descriptor( - "mcp.session.call_tool", - "Call tool on temporary MCP session", - ), - call_handler=self._mcp_session_call_tool, - ) - self.register( - self._builtin_descriptor( - "mcp.session.close", "Close temporary MCP session" - ), - call_handler=self._mcp_session_close, - ) - self.register( - self._builtin_descriptor( - "mcp.global.register", "Register global MCP server" - ), - call_handler=self._mcp_global_register, - ) - self.register( - self._builtin_descriptor("mcp.global.get", "Get global MCP server"), - call_handler=self._mcp_global_get, - ) - self.register( - self._builtin_descriptor("mcp.global.list", "List global MCP servers"), - call_handler=self._mcp_global_list, - ) - self.register( - self._builtin_descriptor("mcp.global.enable", "Enable global MCP server"), - call_handler=self._mcp_global_enable, - ) - self.register( - self._builtin_descriptor("mcp.global.disable", "Disable global MCP server"), - call_handler=self._mcp_global_disable, - ) - self.register( - self._builtin_descriptor( - "mcp.global.unregister", - "Unregister global MCP server", - ), - call_handler=self._mcp_global_unregister, - ) - self.register( - self._builtin_descriptor( - "internal.mcp.local.execute", - "Execute local MCP tool", - ), - call_handler=self._internal_mcp_local_execute, - exposed=False, - ) diff --git a/astrbot/core/sdk_bridge/capabilities/message_history.py b/astrbot/core/sdk_bridge/capabilities/message_history.py deleted file mode 100644 index ebcdb7437..000000000 --- a/astrbot/core/sdk_bridge/capabilities/message_history.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.message.components import component_to_payload_sync - -from astrbot.core.platform.message_session import MessageSession -from astrbot.core.platform.message_type import MessageType -from astrbot.core.platform_message_history_mgr import MessageHistorySender - -from ._host import CapabilityMixinHost - - -def _core_message_type_from_sdk(value: str) -> MessageType: - normalized = str(value).strip().lower() - if normalized == "group": - return MessageType.GROUP_MESSAGE - if normalized == "private": - return MessageType.FRIEND_MESSAGE - if normalized == "other": - return MessageType.OTHER_MESSAGE - raise AstrBotError.invalid_input( - f"Unsupported message history message_type: {value}" - ) - - -def _sdk_message_type_from_core(value: MessageType | str) -> str: - if isinstance(value, MessageType): - if value == MessageType.GROUP_MESSAGE: - return "group" - if value == MessageType.FRIEND_MESSAGE: - return "private" - return "other" - return str(value).strip().lower() - - -class MessageHistoryCapabilityMixin(CapabilityMixinHost): - @staticmethod - def _typed_message_history_session(payload: Any) -> MessageSession: - if not isinstance(payload, dict): - raise AstrBotError.invalid_input( - "message_history capabilities require a session object" - ) - platform_id = str(payload.get("platform_id", "")).strip() - message_type = str(payload.get("message_type", "")).strip() - session_id = str(payload.get("session_id", "")).strip() - if not platform_id or not message_type or not session_id: - raise AstrBotError.invalid_input( - "message_history session requires platform_id, message_type, and session_id" - ) - return MessageSession( - platform_name=platform_id, - message_type=_core_message_type_from_sdk(message_type), - session_id=session_id, - ) - - @staticmethod - def _serialize_session(session: MessageSession) -> dict[str, str]: - return { - "platform_id": str(session.platform_id), - "message_type": _sdk_message_type_from_core(session.message_type), - "session_id": str(session.session_id), - } - - def _serialize_message_history_record(self, record: Any) -> dict[str, Any] | None: - if record is None: - return None - session = getattr(record, "session", None) - sender = getattr(record, "sender", None) - parts = getattr(record, "parts", None) - return { - "id": int(getattr(record, "id", 0) or 0), - "session": ( - self._serialize_session(session) - if isinstance(session, MessageSession) - else {} - ), - "sender": { - "sender_id": ( - str(getattr(sender, "sender_id", "")) - if getattr(sender, "sender_id", None) is not None - else None - ), - "sender_name": ( - str(getattr(sender, "sender_name", "")) - if getattr(sender, "sender_name", None) is not None - else None - ), - }, - "parts": ( - [component_to_payload_sync(part) for part in parts] - if isinstance(parts, list) - else [] - ), - "metadata": ( - dict(getattr(record, "metadata", {})) - if isinstance(getattr(record, "metadata", None), dict) - else {} - ), - "created_at": self._to_iso_datetime(getattr(record, "created_at", None)), - "updated_at": self._to_iso_datetime(getattr(record, "updated_at", None)), - "idempotency_key": ( - str(getattr(record, "idempotency_key", "")) - if getattr(record, "idempotency_key", None) is not None - else None - ), - } - - @staticmethod - def _parse_boundary(raw_value: Any, field_name: str) -> datetime: - text = str(raw_value or "").strip() - if not text: - raise AstrBotError.invalid_input( - f"message_history.{field_name} requires {field_name}" - ) - try: - return datetime.fromisoformat(text) - except ValueError as exc: - raise AstrBotError.invalid_input( - f"message_history.{field_name} requires an ISO datetime string" - ) from exc - - async def _message_history_list( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - raw_limit = self._optional_int(payload.get("limit")) - limit = 50 if raw_limit is None else raw_limit - if limit < 1: - raise AstrBotError.invalid_input("message_history.list requires limit >= 1") - page = await self._star_context.message_history_manager.list( - session, - cursor=( - str(payload.get("cursor")) - if payload.get("cursor") is not None - else None - ), - limit=limit, - ) - return { - "page": { - "records": [ - item - for item in ( - self._serialize_message_history_record(record) - for record in page.records - ) - if item is not None - ], - "next_cursor": page.next_cursor, - "total": page.total, - } - } - - async def _message_history_get_by_id( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - record_id = self._optional_int(payload.get("record_id")) - if record_id is None or record_id < 1: - raise AstrBotError.invalid_input( - "message_history.get_by_id requires record_id >= 1" - ) - record = await self._star_context.message_history_manager.get_by_id( - session, - record_id, - ) - return {"record": self._serialize_message_history_record(record)} - - async def _message_history_append( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - sender_payload = payload.get("sender") - if not isinstance(sender_payload, dict): - raise AstrBotError.invalid_input( - "message_history.append requires sender object" - ) - parts_payload = payload.get("parts") - if not isinstance(parts_payload, list) or any( - not isinstance(item, dict) for item in parts_payload - ): - raise AstrBotError.invalid_input( - "message_history.append requires parts array" - ) - metadata = payload.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - raise AstrBotError.invalid_input( - "message_history.append requires metadata object when provided" - ) - record = await self._star_context.message_history_manager.append( - session, - parts=self._build_core_message_chain(parts_payload).chain, - sender=MessageHistorySender( - sender_id=( - str(sender_payload.get("sender_id")) - if sender_payload.get("sender_id") is not None - else None - ), - sender_name=( - str(sender_payload.get("sender_name")) - if sender_payload.get("sender_name") is not None - else None - ), - ), - metadata=dict(metadata or {}), - idempotency_key=( - str(payload.get("idempotency_key")) - if payload.get("idempotency_key") is not None - else None - ), - ) - return {"record": self._serialize_message_history_record(record)} - - async def _message_history_delete_before( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - deleted_count = await self._star_context.message_history_manager.delete_before( - session, - before=self._parse_boundary(payload.get("before"), "delete_before"), - ) - return {"deleted_count": int(deleted_count)} - - async def _message_history_delete_after( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - deleted_count = await self._star_context.message_history_manager.delete_after( - session, - after=self._parse_boundary(payload.get("after"), "delete_after"), - ) - return {"deleted_count": int(deleted_count)} - - async def _message_history_delete_all( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - session = self._typed_message_history_session(payload.get("session")) - deleted_count = await self._star_context.message_history_manager.delete_all( - session - ) - return {"deleted_count": int(deleted_count)} - - def _register_message_history_capabilities(self) -> None: - self.register( - self._builtin_descriptor("message_history.list", "List message history"), - call_handler=self._message_history_list, - ) - self.register( - self._builtin_descriptor( - "message_history.get_by_id", - "Get message history by id", - ), - call_handler=self._message_history_get_by_id, - ) - self.register( - self._builtin_descriptor( - "message_history.append", "Append message history" - ), - call_handler=self._message_history_append, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_before", - "Delete message history before timestamp", - ), - call_handler=self._message_history_delete_before, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_after", - "Delete message history after timestamp", - ), - call_handler=self._message_history_delete_after, - ) - self.register( - self._builtin_descriptor( - "message_history.delete_all", - "Delete all message history in session", - ), - call_handler=self._message_history_delete_all, - ) diff --git a/astrbot/core/sdk_bridge/capabilities/persona.py b/astrbot/core/sdk_bridge/capabilities/persona.py deleted file mode 100644 index 94db89cab..000000000 --- a/astrbot/core/sdk_bridge/capabilities/persona.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from astrbot_sdk.errors import AstrBotError - -from ._host import CapabilityMixinHost - - -class PersonaCapabilityMixin(CapabilityMixinHost): - def _register_persona_capabilities(self) -> None: - self.register( - self._builtin_descriptor("persona.get", "Get persona"), - call_handler=self._persona_get, - ) - self.register( - self._builtin_descriptor("persona.list", "List personas"), - call_handler=self._persona_list, - ) - self.register( - self._builtin_descriptor("persona.create", "Create persona"), - call_handler=self._persona_create, - ) - self.register( - self._builtin_descriptor("persona.update", "Update persona"), - call_handler=self._persona_update, - ) - self.register( - self._builtin_descriptor("persona.delete", "Delete persona"), - call_handler=self._persona_delete, - ) - - async def _persona_get( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - persona_id = str(payload.get("persona_id", "")).strip() - try: - persona = await self._star_context.persona_manager.get_persona(persona_id) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"persona": self._serialize_persona(persona)} - - async def _persona_list( - self, - _request_id: str, - _payload: dict[str, object], - _token, - ) -> dict[str, object]: - personas = await self._star_context.persona_manager.get_all_personas() - return { - "personas": [ - payload - for payload in ( - self._serialize_persona(persona) for persona in personas - ) - if payload is not None - ] - } - - async def _persona_create( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - raw_persona = payload.get("persona") - if not isinstance(raw_persona, dict): - raise AstrBotError.invalid_input("persona.create requires persona object") - try: - persona = await self._star_context.persona_manager.create_persona( - persona_id=str(raw_persona.get("persona_id", "")), - system_prompt=str(raw_persona.get("system_prompt", "")), - begin_dialogs=self._normalize_persona_dialogs( - raw_persona.get("begin_dialogs") - ), - tools=( - [str(item) for item in raw_persona.get("tools", [])] - if isinstance(raw_persona.get("tools"), list) - else None - ), - skills=( - [str(item) for item in raw_persona.get("skills", [])] - if isinstance(raw_persona.get("skills"), list) - else None - ), - custom_error_message=( - str(raw_persona.get("custom_error_message")) - if raw_persona.get("custom_error_message") is not None - else None - ), - folder_id=( - str(raw_persona.get("folder_id")) - if raw_persona.get("folder_id") is not None - else None - ), - sort_order=int(raw_persona.get("sort_order", 0)), - ) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {"persona": self._serialize_persona(persona)} - - async def _persona_update( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - raw_persona = payload.get("persona") - if not isinstance(raw_persona, dict): - raise AstrBotError.invalid_input("persona.update requires persona object") - persona = await self._star_context.persona_manager.update_persona( - persona_id=str(payload.get("persona_id", "")), - system_prompt=raw_persona.get("system_prompt"), - begin_dialogs=( - self._normalize_persona_dialogs(raw_persona.get("begin_dialogs")) - if "begin_dialogs" in raw_persona - else None - ), - tools=( - [str(item) for item in raw_persona.get("tools", [])] - if isinstance(raw_persona.get("tools"), list) - else raw_persona.get("tools") - ), - skills=( - [str(item) for item in raw_persona.get("skills", [])] - if isinstance(raw_persona.get("skills"), list) - else raw_persona.get("skills") - ), - custom_error_message=raw_persona.get("custom_error_message"), - ) - return {"persona": self._serialize_persona(persona)} - - async def _persona_delete( - self, - _request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, object]: - persona_id = str(payload.get("persona_id", "")).strip() - try: - await self._star_context.persona_manager.delete_persona(persona_id) - except ValueError as exc: - raise AstrBotError.invalid_input(str(exc)) from exc - return {} diff --git a/astrbot/core/sdk_bridge/capabilities/platform.py b/astrbot/core/sdk_bridge/capabilities/platform.py deleted file mode 100644 index 46b3c416b..000000000 --- a/astrbot/core/sdk_bridge/capabilities/platform.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import uuid -from typing import Any - -from astrbot_sdk.errors import AstrBotError - -from astrbot.core.message.components import Image, Plain -from astrbot.core.message.message_event_result import MessageChain - -from ._host import CapabilityMixinHost - - -class PlatformCapabilityMixin(CapabilityMixinHost): - def _register_platform_capabilities(self) -> None: - self.register( - self._builtin_descriptor("platform.send", "Send plain text"), - call_handler=self._platform_send, - ) - self.register( - self._builtin_descriptor("platform.send_image", "Send image"), - call_handler=self._platform_send_image, - ) - self.register( - self._builtin_descriptor("platform.send_chain", "Send message chain"), - call_handler=self._platform_send_chain, - ) - self.register( - self._builtin_descriptor( - "platform.send_by_session", - "Send message chain to a specific session", - ), - call_handler=self._platform_send_by_session, - ) - self.register( - self._builtin_descriptor("platform.get_group", "Get current group data"), - call_handler=self._platform_get_group, - ) - self.register( - self._builtin_descriptor("platform.get_members", "Get group members"), - call_handler=self._platform_get_members, - ) - self.register( - self._builtin_descriptor( - "platform.list_instances", - "List available platform instances", - ), - call_handler=self._platform_list_instances, - ) - - def _register_platform_manager_capabilities(self) -> None: - self.register( - self._builtin_descriptor( - "platform.manager.get_by_id", - "Get platform management snapshot by id", - ), - call_handler=self._platform_manager_get_by_id, - ) - self.register( - self._builtin_descriptor( - "platform.manager.clear_errors", - "Clear platform error records", - ), - call_handler=self._platform_manager_clear_errors, - ) - self.register( - self._builtin_descriptor( - "platform.manager.get_stats", - "Get platform stats by id", - ), - call_handler=self._platform_manager_get_stats, - ) - - async def _platform_send( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session, dispatch_token = self._resolve_dispatch_target(request_id, payload) - self._plugin_bridge.before_platform_send(dispatch_token) - await self._star_context.send_message( - session, - MessageChain([Plain(str(payload.get("text", "")), convert=False)]), - ) - return {"message_id": self._plugin_bridge.mark_platform_send(dispatch_token)} - - async def _platform_send_image( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session, dispatch_token = self._resolve_dispatch_target(request_id, payload) - self._plugin_bridge.before_platform_send(dispatch_token) - image_url = str(payload.get("image_url", "")) - component = ( - Image.fromURL(image_url) - if image_url.startswith(("http://", "https://")) - else Image.fromFileSystem(image_url) - ) - await self._star_context.send_message(session, MessageChain([component])) - return {"message_id": self._plugin_bridge.mark_platform_send(dispatch_token)} - - async def _platform_send_chain( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session, dispatch_token = self._resolve_dispatch_target(request_id, payload) - self._plugin_bridge.before_platform_send(dispatch_token) - chain_payload = payload.get("chain") - if not isinstance(chain_payload, list): - raise AstrBotError.invalid_input( - "platform.send_chain requires a chain array" - ) - await self._star_context.send_message( - session, - self._build_core_message_chain(chain_payload), - ) - return {"message_id": self._plugin_bridge.mark_platform_send(dispatch_token)} - - async def _platform_send_by_session( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - chain_payload = payload.get("chain") - if not isinstance(chain_payload, list): - raise AstrBotError.invalid_input( - "platform.send_by_session requires a chain array" - ) - session = str(payload.get("session", "")) - if not session: - raise AstrBotError.invalid_input( - "platform.send_by_session requires a session" - ) - request_context = self._resolve_event_request_context(request_id, payload) - dispatch_token = None - if request_context is not None and not request_context.cancelled: - dispatch_token = request_context.dispatch_token - self._plugin_bridge.before_platform_send(dispatch_token) - await self._star_context.send_message( - session, - self._build_core_message_chain(chain_payload), - ) - if dispatch_token is not None: - return { - "message_id": self._plugin_bridge.mark_platform_send(dispatch_token) - } - return {"message_id": f"sdk_proactive_{uuid.uuid4().hex}"} - - async def _platform_get_group( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - request_context = self._resolve_current_group_request_context( - request_id, payload - ) - if request_context is None: - return {"group": None} - group = await request_context.event.get_group() - return {"group": self._serialize_group(group)} - - async def _platform_get_members( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - request_context = self._resolve_current_group_request_context( - request_id, payload - ) - if request_context is None: - return {"members": []} - group = await request_context.event.get_group() - serialized_group = self._serialize_group(group) - if serialized_group is None: - return {"members": []} - members = serialized_group.get("members") - return {"members": list(members) if isinstance(members, list) else []} - - async def _platform_list_instances( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - platform_manager = getattr(self._star_context, "platform_manager", None) - if platform_manager is None or not hasattr(platform_manager, "get_insts"): - return {"platforms": []} - platforms_payload: list[dict[str, Any]] = [] - for platform in list(platform_manager.get_insts()): - meta = None - try: - meta = platform.meta() - except Exception: - continue - platform_id = str(getattr(meta, "id", "")).strip() - platform_type = str(getattr(meta, "name", "")).strip() - if not platform_id or not platform_type: - continue - status = getattr(platform, "status", None) - status_value = getattr(status, "value", status) - display_name = str( - getattr(meta, "adapter_display_name", None) or platform_type - ) - platforms_payload.append( - { - "id": platform_id, - "name": display_name, - "type": platform_type, - "status": str(status_value or "unknown"), - } - ) - return {"platforms": platforms_payload} - - async def _platform_manager_get_by_id( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin( - request_id, - "platform.manager.get_by_id", - ) - platform = self._get_platform_inst_by_id(str(payload.get("platform_id", ""))) - return {"platform": self._serialize_platform_snapshot(platform)} - - async def _platform_manager_clear_errors( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin( - request_id, - "platform.manager.clear_errors", - ) - platform = self._get_platform_inst_by_id(str(payload.get("platform_id", ""))) - if platform is None: - raise AstrBotError.invalid_input("Unknown platform_id") - clear_errors = getattr(platform, "clear_errors", None) - if callable(clear_errors): - clear_errors() - return {} - - async def _platform_manager_get_stats( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin( - request_id, - "platform.manager.get_stats", - ) - platform = self._get_platform_inst_by_id(str(payload.get("platform_id", ""))) - if platform is None: - return {"stats": None} - get_stats = getattr(platform, "get_stats", None) - if not callable(get_stats): - return {"stats": None} - return {"stats": self._serialize_platform_stats(get_stats())} diff --git a/astrbot/core/sdk_bridge/capabilities/provider.py b/astrbot/core/sdk_bridge/capabilities/provider.py deleted file mode 100644 index e5823cf6c..000000000 --- a/astrbot/core/sdk_bridge/capabilities/provider.py +++ /dev/null @@ -1,1335 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import contextlib -import json -import uuid -from collections.abc import AsyncIterator -from typing import Any, cast - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.llm.entities import LLMToolSpec, ProviderMeta, ToolCallsResult -from astrbot_sdk.llm.entities import ProviderType as SDKProviderType -from astrbot_sdk.runtime.capability_router import StreamExecution - -from astrbot.core.platform.astr_message_event import AstrMessageEvent - -from ..bridge_base import _get_runtime_provider_types, _get_runtime_tool_types -from ..event_converter import EventConverter -from ._host import CapabilityMixinHost - - -class ProviderCapabilityMixin(CapabilityMixinHost): - def _register_provider_capabilities(self) -> None: - self.register( - self._builtin_descriptor("provider.get_using", "Get active provider"), - call_handler=self._provider_get_using, - ) - self.register( - self._builtin_descriptor("provider.get_by_id", "Get provider by id"), - call_handler=self._provider_get_by_id, - ) - self.register( - self._builtin_descriptor( - "provider.get_current_chat_provider_id", - "Get active chat provider id", - ), - call_handler=self._provider_get_current_chat_provider_id, - ) - self.register( - self._builtin_descriptor("provider.list_all", "List chat providers"), - call_handler=self._provider_list_all, - ) - self.register( - self._builtin_descriptor("provider.list_all_tts", "List tts providers"), - call_handler=self._provider_list_all_tts, - ) - self.register( - self._builtin_descriptor("provider.list_all_stt", "List stt providers"), - call_handler=self._provider_list_all_stt, - ) - self.register( - self._builtin_descriptor( - "provider.list_all_embedding", - "List embedding providers", - ), - call_handler=self._provider_list_all_embedding, - ) - self.register( - self._builtin_descriptor( - "provider.list_all_rerank", - "List rerank providers", - ), - call_handler=self._provider_list_all_rerank, - ) - self.register( - self._builtin_descriptor( - "provider.get_using_tts", - "Get active tts provider", - ), - call_handler=self._provider_get_using_tts, - ) - self.register( - self._builtin_descriptor( - "provider.get_using_stt", - "Get active stt provider", - ), - call_handler=self._provider_get_using_stt, - ) - self.register( - self._builtin_descriptor( - "provider.stt.get_text", - "Transcribe audio with STT provider", - ), - call_handler=self._provider_stt_get_text, - ) - self.register( - self._builtin_descriptor( - "provider.tts.get_audio", - "Synthesize audio with TTS provider", - ), - call_handler=self._provider_tts_get_audio, - ) - self.register( - self._builtin_descriptor( - "provider.tts.support_stream", - "Check whether TTS provider supports native streaming", - ), - call_handler=self._provider_tts_support_stream, - ) - self.register( - self._builtin_descriptor( - "provider.tts.get_audio_stream", - "Stream audio with TTS provider", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._provider_tts_get_audio_stream, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_embedding", - "Get embedding vector", - ), - call_handler=self._provider_embedding_get_embedding, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_embeddings", - "Get embedding vectors in batch", - ), - call_handler=self._provider_embedding_get_embeddings, - ) - self.register( - self._builtin_descriptor( - "provider.embedding.get_dim", - "Get embedding dimension", - ), - call_handler=self._provider_embedding_get_dim, - ) - self.register( - self._builtin_descriptor( - "provider.rerank.rerank", - "Rerank documents", - ), - call_handler=self._provider_rerank_rerank, - ) - self.register( - self._builtin_descriptor( - "llm_tool.manager.get", - "Get registered and active sdk llm tools", - ), - call_handler=self._llm_tool_manager_get, - ) - self.register( - self._builtin_descriptor( - "llm_tool.manager.activate", - "Activate sdk llm tool", - ), - call_handler=self._llm_tool_manager_activate, - ) - self.register( - self._builtin_descriptor( - "llm_tool.manager.deactivate", - "Deactivate sdk llm tool", - ), - call_handler=self._llm_tool_manager_deactivate, - ) - self.register( - self._builtin_descriptor( - "llm_tool.manager.add", - "Register sdk llm tool metadata", - ), - call_handler=self._llm_tool_manager_add, - ) - self.register( - self._builtin_descriptor( - "llm_tool.manager.remove", - "Unregister sdk llm tool metadata", - ), - call_handler=self._llm_tool_manager_remove, - ) - self.register( - self._builtin_descriptor("agent.tool_loop.run", "Run sdk tool loop agent"), - call_handler=self._agent_tool_loop_run, - ) - self.register( - self._builtin_descriptor("agent.registry.list", "List sdk agents"), - call_handler=self._agent_registry_list, - ) - self.register( - self._builtin_descriptor("agent.registry.get", "Get sdk agent"), - call_handler=self._agent_registry_get, - ) - - def _register_provider_manager_capabilities(self) -> None: - self.register( - self._builtin_descriptor("provider.manager.set", "Set active provider"), - call_handler=self._provider_manager_set, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_by_id", - "Get managed provider record by id", - ), - call_handler=self._provider_manager_get_by_id, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_merged_provider_config", - "Get merged managed provider config by id", - ), - call_handler=self._provider_manager_get_merged_provider_config, - ) - self.register( - self._builtin_descriptor( - "provider.manager.load", - "Load a provider instance without persisting config", - ), - call_handler=self._provider_manager_load, - ) - self.register( - self._builtin_descriptor( - "provider.manager.terminate", - "Terminate a loaded provider instance", - ), - call_handler=self._provider_manager_terminate, - ) - self.register( - self._builtin_descriptor( - "provider.manager.create", - "Create and load a provider config", - ), - call_handler=self._provider_manager_create, - ) - self.register( - self._builtin_descriptor( - "provider.manager.update", - "Update and reload a provider config", - ), - call_handler=self._provider_manager_update, - ) - self.register( - self._builtin_descriptor( - "provider.manager.delete", - "Delete a provider config", - ), - call_handler=self._provider_manager_delete, - ) - self.register( - self._builtin_descriptor( - "provider.manager.get_insts", - "List loaded chat provider instances", - ), - call_handler=self._provider_manager_get_insts, - ) - self.register( - self._builtin_descriptor( - "provider.manager.watch_changes", - "Stream provider change events", - supports_stream=True, - cancelable=True, - ), - stream_handler=self._provider_manager_watch_changes, - ) - - @staticmethod - def _provider_to_payload(provider: Any | None) -> dict[str, Any] | None: - if provider is None: - return None - meta = provider.meta() - return ProviderCapabilityMixin._provider_meta_to_payload(meta) - - @staticmethod - def _normalize_sdk_provider_type(value: Any) -> SDKProviderType: - if isinstance(value, SDKProviderType): - return value - raw_provider_type = getattr(value, "provider_type", value) - provider_type_value = ( - str(raw_provider_type.value) - if hasattr(raw_provider_type, "value") - else str(raw_provider_type) - ) - try: - return SDKProviderType(provider_type_value) - except ValueError: - return SDKProviderType.CHAT_COMPLETION - - @classmethod - def _provider_meta_to_payload(cls, meta: Any) -> dict[str, Any]: - provider_type = cls._normalize_sdk_provider_type(meta) - return ProviderMeta( - id=str(getattr(meta, "id", "")), - model=( - str(getattr(meta, "model", "")) - if getattr(meta, "model", None) is not None - else None - ), - type=str(getattr(meta, "type", "")), - provider_type=provider_type, - ).to_payload() - - @classmethod - def _managed_provider_from_config( - cls, - provider_config: dict[str, Any] | None, - *, - loaded: bool, - ) -> dict[str, Any] | None: - if not isinstance(provider_config, dict): - return None - provider_id = str(provider_config.get("id", "")).strip() - provider_type_text = str(provider_config.get("type", "")).strip() - if not provider_id or not provider_type_text: - return None - provider_type = cls._normalize_sdk_provider_type( - provider_config.get("provider_type", SDKProviderType.CHAT_COMPLETION.value) - ) - return { - "id": provider_id, - "model": ( - str(provider_config.get("model")) - if provider_config.get("model") is not None - else None - ), - "type": provider_type_text, - "provider_type": provider_type.value, - "loaded": bool(loaded), - "enabled": bool(provider_config.get("enable", True)), - "provider_source_id": ( - str(provider_config.get("provider_source_id")) - if provider_config.get("provider_source_id") is not None - else None - ), - } - - @classmethod - def _managed_provider_to_payload( - cls, provider: Any | None - ) -> dict[str, Any] | None: - if provider is None: - return None - meta_payload = cls._provider_to_payload(provider) - if meta_payload is None: - return None - provider_config = getattr(provider, "provider_config", None) - return { - **meta_payload, - "loaded": True, - "enabled": bool( - provider_config.get("enable", True) - if isinstance(provider_config, dict) - else True - ), - "provider_source_id": ( - str(provider_config.get("provider_source_id")) - if isinstance(provider_config, dict) - and provider_config.get("provider_source_id") is not None - else None - ), - } - - def _find_provider_config_by_id(self, provider_id: str) -> dict[str, Any] | None: - provider_manager = getattr(self._star_context, "provider_manager", None) - providers_config = getattr(provider_manager, "providers_config", None) - if not isinstance(providers_config, list): - return None - for item in providers_config: - if not isinstance(item, dict): - continue - if str(item.get("id", "")).strip() == provider_id: - return dict(item) - return None - - def _managed_provider_payload_by_id( - self, - provider_id: str, - *, - fallback_config: dict[str, Any] | None = None, - ) -> dict[str, Any] | None: - normalized_provider_id = str(provider_id).strip() - if not normalized_provider_id: - return None - provider = self._star_context.get_provider_by_id(normalized_provider_id) - payload = self._managed_provider_to_payload(provider) - if payload is not None: - return payload - provider_config = self._find_provider_config_by_id(normalized_provider_id) - if provider_config is None: - provider_config = ( - dict(fallback_config) if isinstance(fallback_config, dict) else None - ) - return self._managed_provider_from_config(provider_config, loaded=False) - - def _resolve_current_chat_provider_id( - self, - request_context: Any | None, - ) -> str | None: - if request_context is None: - return None - provider = self._star_context.get_using_provider( - request_context.event.unified_msg_origin - ) - if provider is None: - return None - meta = provider.meta() - return str(getattr(meta, "id", "") or "") - - async def _provider_get_using( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - provider = self._star_context.get_using_provider(payload.get("umo")) - return {"provider": self._provider_to_payload(provider)} - - async def _provider_get_current_chat_provider_id( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - provider = self._star_context.get_using_provider(payload.get("umo")) - if provider is None: - return {"provider_id": None} - return {"provider_id": str(provider.meta().id)} - - async def _provider_get_by_id( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - provider = self._get_provider_by_id(payload, "provider.get_by_id") - return {"provider": self._provider_to_payload(provider)} - - def _provider_list_payload(self, providers: list[Any]) -> dict[str, Any]: - return { - "providers": [ - payload - for payload in ( - self._provider_to_payload(provider) for provider in providers - ) - if payload is not None - ] - } - - async def _provider_list_all( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return self._provider_list_payload(self._star_context.get_all_providers()) - - async def _provider_list_all_tts( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return self._provider_list_payload(self._star_context.get_all_tts_providers()) - - async def _provider_list_all_stt( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return self._provider_list_payload(self._star_context.get_all_stt_providers()) - - async def _provider_list_all_embedding( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return self._provider_list_payload( - self._star_context.get_all_embedding_providers() - ) - - async def _provider_list_all_rerank( - self, - _request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - return self._provider_list_payload( - self._star_context.get_all_rerank_providers() - ) - - async def _provider_get_using_tts( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - provider = self._star_context.get_using_tts_provider(payload.get("umo")) - return {"provider": self._provider_to_payload(provider)} - - async def _provider_get_using_stt( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - provider = self._star_context.get_using_stt_provider(payload.get("umo")) - return {"provider": self._provider_to_payload(provider)} - - @staticmethod - def _tts_stream_texts_from_payload(payload: dict[str, Any]) -> list[str]: - text = payload.get("text") - if isinstance(text, str): - return [text] - text_chunks = payload.get("text_chunks") - if isinstance(text_chunks, list): - chunks = [str(item) for item in text_chunks] - if chunks: - return chunks - raise AstrBotError.invalid_input( - "provider.tts.get_audio_stream requires text or text_chunks" - ) - - def _get_provider_by_id( - self, - payload: dict[str, Any], - capability_name: str, - ) -> Any: - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - f"{capability_name} requires provider_id", - ) - provider = self._star_context.get_provider_by_id(provider_id) - if provider is None: - raise AstrBotError.invalid_input( - f"{capability_name} unknown provider_id: {provider_id}", - ) - return provider - - def _get_typed_provider( - self, - payload: dict[str, Any], - capability_name: str, - provider_label: str, - expected_type: type[Any], - ) -> Any: - provider = self._get_provider_by_id(payload, capability_name) - if not isinstance(provider, expected_type): - raise AstrBotError.invalid_input( - f"{capability_name} requires a {provider_label} provider", - ) - return provider - - async def _provider_stt_get_text( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - stt_provider_cls, _, _, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.stt.get_text", - "speech_to_text", - stt_provider_cls, - ) - return {"text": await provider.get_text(str(payload.get("audio_url", "")))} - - async def _provider_tts_get_audio( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, tts_provider_cls, _, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.tts.get_audio", - "text_to_speech", - tts_provider_cls, - ) - return {"audio_path": await provider.get_audio(str(payload.get("text", "")))} - - async def _provider_tts_support_stream( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, tts_provider_cls, _, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.tts.support_stream", - "text_to_speech", - tts_provider_cls, - ) - return {"supported": bool(provider.support_stream())} - - async def _provider_tts_get_audio_stream( - self, - _request_id: str, - payload: dict[str, Any], - token, - ) -> StreamExecution: - _, tts_provider_cls, _, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.tts.get_audio_stream", - "text_to_speech", - tts_provider_cls, - ) - texts = self._tts_stream_texts_from_payload(payload) - text_queue: asyncio.Queue[str | None] = asyncio.Queue() - audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue() - for text in texts: - await text_queue.put(text) - await text_queue.put(None) - state: dict[str, BaseException] = {} - - async def producer() -> None: - try: - await provider.get_audio_stream(text_queue, audio_queue) - except Exception as exc: # pragma: no cover - provider-specific failures - state["error"] = exc - finally: - await audio_queue.put(None) - - task = asyncio.create_task(producer()) - - async def iterator() -> AsyncIterator[dict[str, Any]]: - try: - while True: - token.raise_if_cancelled() - item = await audio_queue.get() - if item is None: - break - chunk_text: str | None = None - chunk_audio: bytes | bytearray - if isinstance(item, tuple): - chunk_text = str(item[0]) - chunk_audio = item[1] - else: - chunk_audio = item - yield { - "audio_base64": base64.b64encode(bytes(chunk_audio)).decode( - "ascii" - ), - "text": chunk_text, - } - error = state.get("error") - if error is not None: - raise error - finally: - if not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - else: - with contextlib.suppress(Exception): - await task - - def finalize(chunks: list[dict[str, Any]]) -> dict[str, Any]: - return chunks[-1] if chunks else {"audio_base64": "", "text": None} - - return StreamExecution(iterator=iterator(), finalize=finalize) - - async def _provider_embedding_get_embedding( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, _, embedding_provider_cls, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.embedding.get_embedding", - "embedding", - embedding_provider_cls, - ) - return {"embedding": await provider.get_embedding(str(payload.get("text", "")))} - - async def _provider_embedding_get_embeddings( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, _, embedding_provider_cls, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.embedding.get_embeddings", - "embedding", - embedding_provider_cls, - ) - texts = payload.get("texts") - if not isinstance(texts, list): - raise AstrBotError.invalid_input( - "provider.embedding.get_embeddings requires texts", - ) - return { - "embeddings": await provider.get_embeddings([str(item) for item in texts]) - } - - async def _provider_embedding_get_dim( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, _, embedding_provider_cls, _ = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.embedding.get_dim", - "embedding", - embedding_provider_cls, - ) - return {"dim": int(provider.get_dim())} - - async def _provider_rerank_rerank( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - _, _, _, rerank_provider_cls = _get_runtime_provider_types() - provider = self._get_typed_provider( - payload, - "provider.rerank.rerank", - "rerank", - rerank_provider_cls, - ) - documents = payload.get("documents") - if not isinstance(documents, list): - raise AstrBotError.invalid_input( - "provider.rerank.rerank requires documents", - ) - normalized_documents = [str(item) for item in documents] - top_n = payload.get("top_n") - results = await provider.rerank( - str(payload.get("query", "")), - normalized_documents, - int(top_n) if top_n is not None else None, - ) - serialized = [] - for item in results: - index = int(getattr(item, "index", 0)) - serialized.append( - { - "index": index, - "score": float(getattr(item, "relevance_score", 0.0)), - "document": normalized_documents[index] - if 0 <= index < len(normalized_documents) - else "", - } - ) - return {"results": serialized} - - @staticmethod - def _normalize_provider_config_payload( - payload: Any, - capability_name: str, - field_name: str, - ) -> dict[str, Any]: - if not isinstance(payload, dict): - raise AstrBotError.invalid_input( - f"{capability_name} requires {field_name} object" - ) - return dict(payload) - - @staticmethod - def _core_provider_type(value: Any, capability_name: str): - from astrbot.core.provider.entities import ProviderType as CoreProviderType - - normalized = str(value).strip() - try: - return CoreProviderType(normalized) - except ValueError as exc: - raise AstrBotError.invalid_input( - f"{capability_name} requires a valid provider_type" - ) from exc - - async def _provider_manager_set( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.set") - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.set requires provider_id" - ) - await self._star_context.provider_manager.set_provider( - provider_id=provider_id, - provider_type=self._core_provider_type( - payload.get("provider_type"), - "provider.manager.set", - ), - umo=( - str(payload.get("umo")) - if payload.get("umo") is not None and str(payload.get("umo")).strip() - else None - ), - ) - return {} - - async def _provider_manager_get_by_id( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.get_by_id") - provider_id = str(payload.get("provider_id", "")).strip() - return {"provider": self._managed_provider_payload_by_id(provider_id)} - - async def _provider_manager_get_merged_provider_config( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin( - request_id, - "provider.manager.get_merged_provider_config", - ) - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.get_merged_provider_config requires provider_id" - ) - provider_manager = getattr(self._star_context, "provider_manager", None) - get_merged_provider_config = getattr( - provider_manager, - "get_merged_provider_config", - None, - ) - if provider_manager is None or not callable(get_merged_provider_config): - raise AstrBotError.invalid_input( - "Provider manager does not support merged config lookup" - ) - provider_config = self._find_provider_config_by_id(provider_id) - if provider_config is None: - raise AstrBotError.invalid_input( - "provider.manager.get_merged_provider_config unknown provider_id" - ) - merged_config = cast( - dict[str, Any], get_merged_provider_config(provider_config) - ) - return {"config": dict(merged_config)} - - async def _provider_manager_load( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.load") - provider_config = self._normalize_provider_config_payload( - payload.get("provider_config"), - "provider.manager.load", - "provider_config", - ) - await self._star_context.provider_manager.load_provider(provider_config) - provider_id = str(provider_config.get("id", "")).strip() - return { - "provider": self._managed_provider_payload_by_id( - provider_id, - fallback_config=provider_config, - ) - } - - async def _provider_manager_terminate( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.terminate") - provider_id = str(payload.get("provider_id", "")).strip() - if not provider_id: - raise AstrBotError.invalid_input( - "provider.manager.terminate requires provider_id" - ) - await self._star_context.provider_manager.terminate_provider(provider_id) - return {} - - async def _provider_manager_create( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.create") - provider_config = self._normalize_provider_config_payload( - payload.get("provider_config"), - "provider.manager.create", - "provider_config", - ) - await self._star_context.provider_manager.create_provider(provider_config) - provider_id = str(provider_config.get("id", "")).strip() - return {"provider": self._managed_provider_payload_by_id(provider_id)} - - async def _provider_manager_update( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.update") - origin_provider_id = str(payload.get("origin_provider_id", "")).strip() - if not origin_provider_id: - raise AstrBotError.invalid_input( - "provider.manager.update requires origin_provider_id" - ) - new_config = self._normalize_provider_config_payload( - payload.get("new_config"), - "provider.manager.update", - "new_config", - ) - await self._star_context.provider_manager.update_provider( - origin_provider_id, - new_config, - ) - target_provider_id = str(new_config.get("id") or origin_provider_id).strip() - return {"provider": self._managed_provider_payload_by_id(target_provider_id)} - - async def _provider_manager_delete( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.delete") - provider_id = ( - str(payload.get("provider_id")).strip() - if payload.get("provider_id") is not None - else None - ) - provider_source_id = ( - str(payload.get("provider_source_id")).strip() - if payload.get("provider_source_id") is not None - else None - ) - if not provider_id and not provider_source_id: - raise AstrBotError.invalid_input( - "provider.manager.delete requires provider_id or provider_source_id" - ) - await self._star_context.provider_manager.delete_provider( - provider_id=provider_id or None, - provider_source_id=provider_source_id or None, - ) - return {} - - async def _provider_manager_get_insts( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - self._require_reserved_plugin(request_id, "provider.manager.get_insts") - provider_manager = getattr(self._star_context, "provider_manager", None) - if provider_manager is None or not hasattr(provider_manager, "get_insts"): - return {"providers": []} - return { - "providers": [ - payload - for payload in ( - self._managed_provider_to_payload(provider) - for provider in list(provider_manager.get_insts()) - ) - if payload is not None - ] - } - - async def _provider_manager_watch_changes( - self, - request_id: str, - _payload: dict[str, Any], - token, - ) -> StreamExecution: - self._require_reserved_plugin(request_id, "provider.manager.watch_changes") - provider_manager = getattr(self._star_context, "provider_manager", None) - if provider_manager is None or not hasattr( - provider_manager, "register_provider_change_hook" - ): - raise AstrBotError.invalid_input("Provider manager does not support hooks") - unregister_hook = getattr( - provider_manager, - "unregister_provider_change_hook", - None, - ) - queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - loop = asyncio.get_running_loop() - - def hook(provider_id: str, provider_type: Any, umo: str | None) -> None: - event = { - "provider_id": str(provider_id), - "provider_type": self._normalize_sdk_provider_type(provider_type).value, - "umo": str(umo) if umo is not None else None, - } - loop.call_soon_threadsafe(queue.put_nowait, event) - - provider_manager.register_provider_change_hook(hook) - - async def iterator() -> AsyncIterator[dict[str, Any]]: - try: - while True: - token.raise_if_cancelled() - yield await queue.get() - finally: - if callable(unregister_hook): - unregister_hook(hook) - - return StreamExecution( - iterator=iterator(), - finalize=lambda _chunks: {}, - collect_chunks=False, - ) - - async def _llm_tool_manager_get( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "registered": [ - item.to_payload() - for item in self._plugin_bridge.get_registered_llm_tools(plugin_id) - ], - "active": [ - item.to_payload() - for item in self._plugin_bridge.get_active_llm_tools(plugin_id) - ], - } - - async def _llm_tool_manager_activate( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "activated": self._plugin_bridge.activate_llm_tool( - plugin_id, str(payload.get("name", "")) - ) - } - - async def _llm_tool_manager_deactivate( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "deactivated": self._plugin_bridge.deactivate_llm_tool( - plugin_id, str(payload.get("name", "")) - ) - } - - async def _llm_tool_manager_add( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - tools_payload = payload.get("tools") - if not isinstance(tools_payload, list): - raise AstrBotError.invalid_input("llm_tool.manager.add requires tools list") - tools = [ - LLMToolSpec.from_payload(item) - for item in tools_payload - if isinstance(item, dict) - ] - return {"names": self._plugin_bridge.add_llm_tools(plugin_id, tools)} - - async def _llm_tool_manager_remove( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "removed": self._plugin_bridge.remove_llm_tool( - plugin_id, - str(payload.get("name", "")), - ) - } - - async def _agent_registry_list( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - return { - "agents": [ - item.to_payload() - for item in self._plugin_bridge.get_registered_agents(plugin_id) - ] - } - - async def _agent_registry_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - agent = self._plugin_bridge.get_registered_agent( - plugin_id, str(payload.get("name", "")) - ) - return {"agent": agent.to_payload() if agent is not None else None} - - def _select_llm_tools_for_request( - self, - plugin_id: str, - payload: dict[str, Any], - ) -> list[LLMToolSpec]: - active_specs = { - item.name: item - for item in self._plugin_bridge.get_request_tool_specs(plugin_id) - } - requested = payload.get("tool_names") - if not isinstance(requested, list) or not requested: - return list(active_specs.values()) - names = [str(item) for item in requested if str(item).strip()] - return [active_specs[name] for name in names if name in active_specs] - - def _make_sdk_tool_handler( - self, - *, - plugin_id: str, - tool_spec: LLMToolSpec, - tool_call_timeout: int, - ): - async def _handler(event: AstrMessageEvent, **tool_args: Any) -> str | None: - record = self._plugin_bridge._records.get(plugin_id) - if record is None or record.session is None: - return json.dumps( - ToolCallsResult( - tool_name=tool_spec.name, - content="SDK plugin worker is unavailable", - success=False, - ).to_payload(), - ensure_ascii=False, - ) - request_id = f"sdk_tool_{plugin_id}_{uuid.uuid4().hex}" - dispatch_token = ( - self._plugin_bridge._get_dispatch_token(event) or uuid.uuid4().hex - ) - event_payload = EventConverter.core_to_sdk( - event, - dispatch_token=dispatch_token, - plugin_id=plugin_id, - request_id=request_id, - ) - call_payload = { - "plugin_id": plugin_id, - "tool_name": tool_spec.name, - "handler_ref": tool_spec.handler_ref, - "tool_args": json.loads( - json.dumps(tool_args, ensure_ascii=False, default=str) - ), - "event": event_payload, - } - try: - if tool_spec.handler_capability == "internal.mcp.local.execute": - handler_ref = json.loads(tool_spec.handler_ref or "{}") - output = await asyncio.wait_for( - self.execute( - "internal.mcp.local.execute", - { - "plugin_id": plugin_id, - "server_name": str( - handler_ref.get("server_name", "") - ).strip(), - "tool_name": str( - handler_ref.get("tool_name", "") - ).strip(), - "tool_args": call_payload["tool_args"], - }, - stream=False, - cancel_token=None, - request_id=request_id, - ), - timeout=tool_call_timeout, - ) - elif tool_spec.handler_capability: - output = await asyncio.wait_for( - record.session.invoke_capability( - tool_spec.handler_capability, - call_payload, - request_id=request_id, - ), - timeout=tool_call_timeout, - ) - else: - output = await asyncio.wait_for( - record.session.invoke_capability( - "internal.llm_tool.execute", - call_payload, - request_id=request_id, - ), - timeout=tool_call_timeout, - ) - except TimeoutError: - return json.dumps( - ToolCallsResult( - tool_name=tool_spec.name, - content=( - f"Tool execution timeout after {tool_call_timeout} seconds" - ), - success=False, - ).to_payload(), - ensure_ascii=False, - ) - except Exception as exc: - return json.dumps( - ToolCallsResult( - tool_name=tool_spec.name, - content=f"Tool execution failed: {exc}", - success=False, - ).to_payload(), - ensure_ascii=False, - ) - if not isinstance(output, dict): - return str(output) - content = output.get("content") - if output.get("success", True): - # Keep None distinct from an empty string so tools can signal - # "no content" without fabricating a textual result. - return None if content is None else str(content) - return json.dumps( - ToolCallsResult( - tool_name=tool_spec.name, - content=str(content or ""), - success=False, - ).to_payload(), - ensure_ascii=False, - ) - - return _handler - - def _build_sdk_toolset( - self, - *, - plugin_id: str, - payload: dict[str, Any], - tool_call_timeout: int, - ) -> Any | None: - tool_specs = self._select_llm_tools_for_request(plugin_id, payload) - if not tool_specs: - return None - function_tool_cls, tool_set_cls = _get_runtime_tool_types() - tool_set = tool_set_cls() - for tool_spec in tool_specs: - tool_set.add_tool( - function_tool_cls( - name=tool_spec.name, - description=tool_spec.description, - parameters=tool_spec.parameters_schema, - handler=self._make_sdk_tool_handler( - plugin_id=plugin_id, - tool_spec=tool_spec, - tool_call_timeout=tool_call_timeout, - ), - ) - ) - return tool_set - - def _llm_response_to_payload(self, response: Any) -> dict[str, Any]: - usage = None - if response.usage is not None: - usage = { - "input_tokens": response.usage.input, - "output_tokens": response.usage.output, - "total_tokens": response.usage.total, - } - return { - "text": response.completion_text, - "usage": usage, - "finish_reason": "tool_calls" if response.tools_call_ids else "stop", - "tool_calls": response.to_openai_tool_calls(), - "role": response.role, - "reasoning_content": response.reasoning_content or None, - "reasoning_signature": response.reasoning_signature, - } - - async def _agent_tool_loop_run( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - request_context = self._resolve_event_request_context(request_id, payload) - if request_context is None: - raise AstrBotError.invalid_input( - "tool_loop_agent currently requires a message-bound SDK request" - ) - provider_id = str( - payload.get("provider_id") or "" - ).strip() or self._resolve_current_chat_provider_id(request_context) - if not provider_id: - raise AstrBotError.invalid_input("No active chat provider is available") - tool_call_timeout = int(payload.get("tool_call_timeout") or 60) - llm_resp = await self._star_context.tool_loop_agent( - event=request_context.event, - chat_provider_id=provider_id, - prompt=( - str(payload.get("prompt")) - if payload.get("prompt") is not None - else None - ), - image_urls=[ - str(item) - for item in payload.get("image_urls", []) - if isinstance(item, str) - ], - tools=self._build_sdk_toolset( - plugin_id=plugin_id, - payload=payload, - tool_call_timeout=tool_call_timeout, - ), - system_prompt=str(payload.get("system_prompt") or ""), - contexts=[ - dict(item) - for item in payload.get("contexts", []) - if isinstance(item, dict) - ], - max_steps=int(payload.get("max_steps") or 30), - tool_call_timeout=tool_call_timeout, - ) - return self._llm_response_to_payload(llm_resp) diff --git a/astrbot/core/sdk_bridge/capabilities/session.py b/astrbot/core/sdk_bridge/capabilities/session.py deleted file mode 100644 index 0f992ff75..000000000 --- a/astrbot/core/sdk_bridge/capabilities/session.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from astrbot_sdk.errors import AstrBotError - -from ..bridge_base import _get_runtime_sp -from ._host import CapabilityMixinHost - - -class SessionCapabilityMixin(CapabilityMixinHost): - def _register_session_capabilities(self) -> None: - self.register( - self._builtin_descriptor( - "session.plugin.is_enabled", - "Get session plugin enabled state", - ), - call_handler=self._session_plugin_is_enabled, - ) - self.register( - self._builtin_descriptor( - "session.plugin.filter_handlers", - "Filter handler metadata by session plugin config", - ), - call_handler=self._session_plugin_filter_handlers, - ) - self.register( - self._builtin_descriptor( - "session.service.is_llm_enabled", - "Get session LLM enabled state", - ), - call_handler=self._session_service_is_llm_enabled, - ) - self.register( - self._builtin_descriptor( - "session.service.set_llm_status", - "Set session LLM enabled state", - ), - call_handler=self._session_service_set_llm_status, - ) - self.register( - self._builtin_descriptor( - "session.service.is_tts_enabled", - "Get session TTS enabled state", - ), - call_handler=self._session_service_is_tts_enabled, - ) - self.register( - self._builtin_descriptor( - "session.service.set_tts_status", - "Set session TTS enabled state", - ), - call_handler=self._session_service_set_tts_status, - ) - - async def _load_session_plugin_config(self, session_id: str) -> dict[str, Any]: - raw_config = await _get_runtime_sp().get_async( - scope="umo", - scope_id=session_id, - key="session_plugin_config", - default={}, - ) - return self._normalize_session_scoped_config(raw_config, session_id) - - async def _load_session_service_config(self, session_id: str) -> dict[str, Any]: - raw_config = await _get_runtime_sp().get_async( - scope="umo", - scope_id=session_id, - key="session_service_config", - default={}, - ) - return self._normalize_session_scoped_config(raw_config, session_id) - - async def _session_plugin_is_enabled( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - plugin_name = str(payload.get("plugin_name", "")).strip() - config = await self._load_session_plugin_config(session_id) - enabled_plugins = { - str(item) for item in config.get("enabled_plugins", []) if str(item).strip() - } - disabled_plugins = { - str(item) - for item in config.get("disabled_plugins", []) - if str(item).strip() - } - if ( - plugin_name in disabled_plugins - and plugin_name not in self._reserved_plugin_names() - ): - return {"enabled": False} - if plugin_name in enabled_plugins: - return {"enabled": True} - return {"enabled": True} - - async def _session_plugin_filter_handlers( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - handlers = payload.get("handlers") - if not isinstance(handlers, list): - raise AstrBotError.invalid_input( - "session.plugin.filter_handlers requires a handlers array" - ) - config = await self._load_session_plugin_config(session_id) - disabled_plugins = { - str(item) - for item in config.get("disabled_plugins", []) - if str(item).strip() - } - reserved_plugins = self._reserved_plugin_names() - filtered = [] - for item in handlers: - if not isinstance(item, dict): - continue - plugin_name = str(item.get("plugin_name", "")).strip() - if ( - plugin_name - and plugin_name in disabled_plugins - and plugin_name not in reserved_plugins - ): - continue - filtered.append(dict(item)) - return {"handlers": filtered} - - async def _session_service_is_llm_enabled( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - config = await self._load_session_service_config(session_id) - return {"enabled": bool(config.get("llm_enabled", True))} - - async def _session_service_set_llm_status( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - config = await self._load_session_service_config(session_id) - config["llm_enabled"] = bool(payload.get("enabled", False)) - await _get_runtime_sp().put_async( - scope="umo", - scope_id=session_id, - key="session_service_config", - value=config, - ) - return {} - - async def _session_service_is_tts_enabled( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - config = await self._load_session_service_config(session_id) - return {"enabled": bool(config.get("tts_enabled", True))} - - async def _session_service_set_tts_status( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - session_id = str(payload.get("session", "")).strip() - config = await self._load_session_service_config(session_id) - config["tts_enabled"] = bool(payload.get("enabled", False)) - await _get_runtime_sp().put_async( - scope="umo", - scope_id=session_id, - key="session_service_config", - value=config, - ) - return {} diff --git a/astrbot/core/sdk_bridge/capabilities/skill.py b/astrbot/core/sdk_bridge/capabilities/skill.py deleted file mode 100644 index 73fcbab61..000000000 --- a/astrbot/core/sdk_bridge/capabilities/skill.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from astrbot.core import logger - -from ._host import CapabilityMixinHost - - -class SkillCapabilityMixin(CapabilityMixinHost): - def _register_skill_capabilities(self) -> None: - self.register( - self._builtin_descriptor("skill.register", "Register SDK skill"), - call_handler=self._skill_register, - ) - self.register( - self._builtin_descriptor("skill.unregister", "Unregister SDK skill"), - call_handler=self._skill_unregister, - ) - self.register( - self._builtin_descriptor("skill.list", "List SDK skills"), - call_handler=self._skill_list, - ) - - async def _skill_register( - self, - request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, str]: - plugin_id = self._resolve_plugin_id(request_id) - result = self._plugin_bridge.register_skill( - plugin_id=plugin_id, - name=str(payload.get("name", "")), - path=str(payload.get("path", "")), - description=str(payload.get("description", "")), - ) - await self._sync_registered_skills_to_sandboxes() - return result - - async def _skill_unregister( - self, - request_id: str, - payload: dict[str, object], - _token, - ) -> dict[str, bool]: - plugin_id = self._resolve_plugin_id(request_id) - removed = self._plugin_bridge.unregister_skill( - plugin_id=plugin_id, - name=str(payload.get("name", "")), - ) - if removed: - await self._sync_registered_skills_to_sandboxes() - return {"removed": removed} - - async def _skill_list( - self, - request_id: str, - _payload: dict[str, object], - _token, - ) -> dict[str, list[dict[str, str]]]: - plugin_id = self._resolve_plugin_id(request_id) - return {"skills": self._plugin_bridge.list_registered_skills(plugin_id)} - - async def _sync_registered_skills_to_sandboxes(self) -> None: - try: - from astrbot.core.computer.computer_client import ( - sync_skills_to_active_sandboxes, - ) - - await sync_skills_to_active_sandboxes() - except Exception as exc: - logger.warning( - "Failed to sync skills to active sandboxes after SDK skill update: %s", - exc, - ) diff --git a/astrbot/core/sdk_bridge/capabilities/system.py b/astrbot/core/sdk_bridge/capabilities/system.py deleted file mode 100644 index 7321e56be..000000000 --- a/astrbot/core/sdk_bridge/capabilities/system.py +++ /dev/null @@ -1,596 +0,0 @@ -from __future__ import annotations - -import asyncio -import uuid -from collections.abc import AsyncIterator -from pathlib import Path -from typing import Any - -from astrbot_sdk.errors import AstrBotError - -from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.utils.astrbot_path import get_astrbot_data_path - -from ..bridge_base import ( - _EventStreamState, - _get_runtime_astrbot_config, - _get_runtime_file_token_service, - _get_runtime_html_renderer, -) -from ._host import CapabilityMixinHost - - -class SystemCapabilityMixin(CapabilityMixinHost): - @staticmethod - def _overlay_request_id(request_id: str, payload: dict[str, Any]) -> str: - scope_request_id = payload.get("_request_scope_id") - if isinstance(scope_request_id, str) and scope_request_id.strip(): - return scope_request_id - return request_id - - def _register_system_capabilities(self) -> None: - self.register( - self._builtin_descriptor("system.get_data_dir", "Get plugin data dir"), - call_handler=self._system_get_data_dir, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.text_to_image", "Render text to image"), - call_handler=self._system_text_to_image, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.html_render", "Render html template"), - call_handler=self._system_html_render, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.file.register", "Register file token"), - call_handler=self._system_file_register, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.file.handle", "Resolve file token"), - call_handler=self._system_file_handle, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.session_waiter.register", - "Register sdk session waiter", - ), - call_handler=self._system_session_waiter_register, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.session_waiter.unregister", - "Unregister sdk session waiter", - ), - call_handler=self._system_session_waiter_unregister, - exposed=False, - ) - self.register( - self._builtin_descriptor("system.event.react", "Send sdk event reaction"), - call_handler=self._system_event_react, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_typing", - "Send sdk event typing state", - ), - call_handler=self._system_event_send_typing, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming", - "Send sdk event streaming chunks", - ), - call_handler=self._system_event_send_streaming, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming_chunk", - "Push sdk event streaming chunk", - ), - call_handler=self._system_event_send_streaming_chunk, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.send_streaming_close", - "Close sdk event streaming session", - ), - call_handler=self._system_event_send_streaming_close, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.llm.get_state", - "Read sdk request llm state", - ), - call_handler=self._system_event_llm_get_state, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.llm.request", - "Request default llm for current sdk request", - ), - call_handler=self._system_event_llm_request, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.result.get", - "Read sdk request result", - ), - call_handler=self._system_event_result_get, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.result.set", - "Write sdk request result", - ), - call_handler=self._system_event_result_set, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.result.clear", - "Clear sdk request result", - ), - call_handler=self._system_event_result_clear, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.handler_whitelist.get", - "Read sdk request handler whitelist", - ), - call_handler=self._system_event_handler_whitelist_get, - exposed=False, - ) - self.register( - self._builtin_descriptor( - "system.event.handler_whitelist.set", - "Write sdk request handler whitelist", - ), - call_handler=self._system_event_handler_whitelist_set, - exposed=False, - ) - - def _register_registry_capabilities(self) -> None: - self.register( - self._builtin_descriptor( - "registry.get_handlers_by_event_type", - "List SDK handlers by event type", - ), - call_handler=self._registry_get_handlers_by_event_type, - ) - self.register( - self._builtin_descriptor( - "registry.get_handler_by_full_name", - "Get SDK handler metadata by full name", - ), - call_handler=self._registry_get_handler_by_full_name, - ) - self.register( - self._builtin_descriptor( - "registry.command.register", - "Register dynamic command route", - ), - call_handler=self._registry_command_register, - ) - - async def _system_get_data_dir( - self, - request_id: str, - _payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - data_dir = Path(get_astrbot_data_path()) / "plugin_data" / plugin_id - data_dir.mkdir(parents=True, exist_ok=True) - return {"path": str(data_dir.resolve())} - - async def _system_text_to_image( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - config_obj = self._star_context.get_config() - template_name = None - if hasattr(config_obj, "get"): - try: - template_name = config_obj.get("t2i_active_template") - except Exception: - template_name = None - result = await _get_runtime_html_renderer().render_t2i( - str(payload.get("text", "")), - return_url=bool(payload.get("return_url", True)), - template_name=template_name, - ) - return {"result": result} - - async def _system_html_render( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - data = payload.get("data") - if not isinstance(data, dict): - raise AstrBotError.invalid_input("system.html_render requires object data") - options = payload.get("options") - if options is not None and not isinstance(options, dict): - raise AstrBotError.invalid_input( - "system.html_render options must be an object or null" - ) - result = await _get_runtime_html_renderer().render_custom_template( - str(payload.get("tmpl", "")), - data, - return_url=bool(payload.get("return_url", True)), - options=options, - ) - return {"result": result} - - async def _system_file_register( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - path = str(payload.get("path", "")).strip() - if not path: - raise AstrBotError.invalid_input("system.file.register requires path") - raw_timeout = payload.get("timeout") - timeout: float | None - if raw_timeout is None: - timeout = None - else: - try: - timeout = float(raw_timeout) - except (TypeError, ValueError) as exc: - raise AstrBotError.invalid_input( - "system.file.register timeout must be a number or null" - ) from exc - file_token = await _get_runtime_file_token_service().register_file( - path, timeout - ) - callback_host = _get_runtime_astrbot_config().get("callback_api_base") - if not callback_host: - raise AstrBotError.invalid_input( - "callback_api_base is required for system.file.register" - ) - base_url = str(callback_host).rstrip("/") - return {"token": file_token, "url": f"{base_url}/api/file/{file_token}"} - - async def _system_file_handle( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - file_token = str(payload.get("token", "")).strip() - if not file_token: - raise AstrBotError.invalid_input("system.file.handle requires token") - path = await _get_runtime_file_token_service().handle_file(file_token) - return {"path": str(path)} - - async def _system_session_waiter_register( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - self._plugin_bridge.register_session_waiter( - plugin_id=plugin_id, - session_key=str(payload.get("session_key", "")), - ) - return {} - - async def _system_session_waiter_unregister( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_id = self._resolve_plugin_id(request_id) - self._plugin_bridge.unregister_session_waiter( - plugin_id=plugin_id, - session_key=str(payload.get("session_key", "")), - ) - return {} - - async def _system_event_react( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - request_context = self._resolve_event_request_context(request_id, payload) - if request_context is None or request_context.cancelled: - return {"supported": False} - self._plugin_bridge.before_platform_send(request_context.dispatch_token) - await request_context.event.react(str(payload.get("emoji", ""))) - return { - "supported": bool( - self._plugin_bridge.mark_platform_send(request_context.dispatch_token) - ) - } - - async def _system_event_send_typing( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - request_context = self._resolve_event_request_context(request_id, payload) - if request_context is None or request_context.cancelled: - return {"supported": False} - if type(request_context.event).send_typing is AstrMessageEvent.send_typing: - return {"supported": False} - await request_context.event.send_typing() - return {"supported": True} - - async def _system_event_send_streaming( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - request_context = self._resolve_event_request_context(request_id, payload) - if request_context is None or request_context.cancelled: - return {"supported": False} - if ( - type(request_context.event).send_streaming - is AstrMessageEvent.send_streaming - ): - return {"supported": False} - self._plugin_bridge.before_platform_send(request_context.dispatch_token) - queue: asyncio.Queue[MessageChain | None] = asyncio.Queue() - - async def iterator() -> AsyncIterator[MessageChain]: - while True: - chunk = await queue.get() - if chunk is None or request_context.cancelled: - return - yield chunk - await asyncio.sleep(0) - - stream_id = uuid.uuid4().hex - task = asyncio.create_task( - request_context.event.send_streaming( - iterator(), - use_fallback=bool(payload.get("use_fallback", False)), - ) - ) - self._event_streams[stream_id] = _EventStreamState( - request_context=request_context, - queue=queue, - task=task, - ) - return {"supported": True, "stream_id": stream_id} - - async def _system_event_send_streaming_chunk( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - stream_state = self._event_streams.get(str(payload.get("stream_id", ""))) - if stream_state is None: - raise AstrBotError.invalid_input("Unknown sdk event streaming session") - if stream_state.request_context.cancelled: - raise AstrBotError.cancelled("The SDK request has been cancelled") - chain_payload = payload.get("chain") - if not isinstance(chain_payload, list): - raise AstrBotError.invalid_input( - "system.event.send_streaming_chunk requires a chain array" - ) - await stream_state.queue.put(self._build_core_message_chain(chain_payload)) - return {} - - async def _system_event_send_streaming_close( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - stream_id = str(payload.get("stream_id", "")) - stream_state = self._event_streams.pop(stream_id, None) - if stream_state is None: - raise AstrBotError.invalid_input("Unknown sdk event streaming session") - await stream_state.queue.put(None) - try: - await stream_state.task - finally: - self._event_streams.pop(stream_id, None) - return { - "supported": bool( - self._plugin_bridge.mark_platform_send( - stream_state.request_context.dispatch_token - ) - ) - } - - async def _system_event_llm_get_state( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - overlay = self._plugin_bridge.get_request_overlay_by_request_id( - overlay_request_id - ) - should_call_llm = self._plugin_bridge.get_should_call_llm_for_request( - overlay_request_id - ) - return { - "should_call_llm": bool(should_call_llm), - "requested_llm": bool(overlay.requested_llm) - if overlay is not None - else False, - } - - async def _system_event_llm_request( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - self._plugin_bridge.request_llm_for_request(overlay_request_id) - return await self._system_event_llm_get_state( - request_id, - {"_request_scope_id": overlay_request_id}, - _token, - ) - - async def _system_event_result_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - return { - "result": self._plugin_bridge.get_result_payload_for_request( - overlay_request_id - ) - } - - async def _system_event_result_set( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - result_payload = payload.get("result") - if not isinstance(result_payload, dict): - raise AstrBotError.invalid_input( - "system.event.result.set requires an object result payload" - ) - overlay_request_id = self._overlay_request_id(request_id, payload) - if not self._plugin_bridge.set_result_for_request( - overlay_request_id, - result_payload, - ): - raise AstrBotError.cancelled("The SDK request overlay has been closed") - return { - "result": self._plugin_bridge.get_result_payload_for_request( - overlay_request_id - ) - } - - async def _system_event_result_clear( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - self._plugin_bridge.clear_result_for_request(overlay_request_id) - return {} - - async def _system_event_handler_whitelist_get( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - overlay_request_id = self._overlay_request_id(request_id, payload) - plugin_names = self._plugin_bridge.get_handler_whitelist_for_request( - overlay_request_id - ) - if plugin_names is None: - return {"plugin_names": None} - return {"plugin_names": sorted(plugin_names)} - - async def _system_event_handler_whitelist_set( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - plugin_names_payload = payload.get("plugin_names") - plugin_names: set[str] | None - if plugin_names_payload is None: - plugin_names = None - elif isinstance(plugin_names_payload, list): - plugin_names = { - str(item) for item in plugin_names_payload if str(item).strip() - } - else: - raise AstrBotError.invalid_input( - "system.event.handler_whitelist.set requires a string array or null" - ) - overlay_request_id = self._overlay_request_id(request_id, payload) - if not self._plugin_bridge.set_handler_whitelist_for_request( - overlay_request_id, - plugin_names, - ): - raise AstrBotError.cancelled("The SDK request overlay has been closed") - return await self._system_event_handler_whitelist_get( - request_id, - {"_request_scope_id": overlay_request_id}, - _token, - ) - - async def _registry_get_handlers_by_event_type( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - event_type = str(payload.get("event_type", "")).strip() - return {"handlers": self._plugin_bridge.get_handlers_by_event_type(event_type)} - - async def _registry_get_handler_by_full_name( - self, - _request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - full_name = str(payload.get("full_name", "")).strip() - return {"handler": self._plugin_bridge.get_handler_by_full_name(full_name)} - - async def _registry_command_register( - self, - request_id: str, - payload: dict[str, Any], - _token, - ) -> dict[str, Any]: - source_event_type = str(payload.get("source_event_type", "")).strip() - if source_event_type not in {"astrbot_loaded", "platform_loaded"}: - raise AstrBotError.invalid_input( - "register_commands is only available in astrbot_loaded/platform_loaded events" - ) - if bool(payload.get("ignore_prefix", False)): - raise AstrBotError.invalid_input( - "register_commands(ignore_prefix=True) is unsupported in SDK runtime" - ) - priority_value = payload.get("priority", 0) - if isinstance(priority_value, bool) or not isinstance(priority_value, int): - raise AstrBotError.invalid_input( - "registry.command.register priority must be an integer" - ) - plugin_id = self._resolve_plugin_id(request_id) - self._plugin_bridge.register_dynamic_command_route( - plugin_id=plugin_id, - command_name=str(payload.get("command_name", "")), - handler_full_name=str(payload.get("handler_full_name", "")), - desc=str(payload.get("desc", "")), - priority=priority_value, - use_regex=bool(payload.get("use_regex", False)), - ) - return {} diff --git a/astrbot/core/sdk_bridge/capability_bridge.py b/astrbot/core/sdk_bridge/capability_bridge.py deleted file mode 100644 index c827f26d0..000000000 --- a/astrbot/core/sdk_bridge/capability_bridge.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from .bridge_base import CapabilityBridgeBase -from .capabilities import ( - BasicCapabilityMixin, - ConversationCapabilityMixin, - KnowledgeBaseCapabilityMixin, - LLMCapabilityMixin, - MCPCapabilityMixin, - MessageHistoryCapabilityMixin, - PersonaCapabilityMixin, - PlatformCapabilityMixin, - ProviderCapabilityMixin, - SessionCapabilityMixin, - SkillCapabilityMixin, - SystemCapabilityMixin, -) -from .event_converter import EventConverter - -if TYPE_CHECKING: - from astrbot.core.star.context import Context as StarContext - -__all__ = ["CoreCapabilityBridge", "EventConverter"] - - -class CoreCapabilityBridge( - SystemCapabilityMixin, - ProviderCapabilityMixin, - MCPCapabilityMixin, - PlatformCapabilityMixin, - KnowledgeBaseCapabilityMixin, - MessageHistoryCapabilityMixin, - ConversationCapabilityMixin, - PersonaCapabilityMixin, - SessionCapabilityMixin, - SkillCapabilityMixin, - LLMCapabilityMixin, - BasicCapabilityMixin, - CapabilityBridgeBase, -): - def __init__(self, *, star_context: StarContext, plugin_bridge) -> None: - self._star_context = star_context - self._plugin_bridge = plugin_bridge - self._event_streams: dict[str, Any] = {} - self._memory_backends_by_plugin: dict[str, Any] = {} - self._memory_index_by_plugin: dict[str, dict[str, dict[str, Any]]] = {} - self._memory_dirty_keys_by_plugin: dict[str, set[str]] = {} - self._memory_expires_at_by_plugin: dict[str, dict[str, Any]] = {} - # CapabilityRouter.__init__() registers the built-in capability groups - # declared by this bridge and its mixins before extended groups are added. - super().__init__() - self._register_provider_capabilities() - self._register_provider_manager_capabilities() - self._register_mcp_capabilities() - self._register_platform_manager_capabilities() - self._register_persona_capabilities() - self._register_conversation_capabilities() - self._register_message_history_capabilities() - self._register_kb_capabilities() - self._register_skill_capabilities() - self._register_system_capabilities() - self._register_registry_capabilities() - self._register_db_capabilities() - self._register_memory_capabilities() - self._register_http_capabilities() - self._register_metadata_capabilities() diff --git a/astrbot/core/sdk_bridge/event_converter.py b/astrbot/core/sdk_bridge/event_converter.py deleted file mode 100644 index 351dd5158..000000000 --- a/astrbot/core/sdk_bridge/event_converter.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any - -from astrbot_sdk.message.components import component_to_payload_sync - -if TYPE_CHECKING: - from astrbot.core.platform.astr_message_event import AstrMessageEvent - - -class EventConverter: - """Convert legacy AstrBot events into SDK payloads.""" - - _DROP_VALUE = object() - - @classmethod - def _sanitize_extra_value(cls, value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, (list, tuple)): - items = [] - for item in value: - sanitized = cls._sanitize_extra_value(item) - if sanitized is not cls._DROP_VALUE: - items.append(sanitized) - return items - if isinstance(value, dict): - sanitized_dict: dict[str, Any] = {} - for key, item in value.items(): - sanitized = cls._sanitize_extra_value(item) - if sanitized is not cls._DROP_VALUE: - sanitized_dict[str(key)] = sanitized - return sanitized_dict - try: - json.dumps(value) - except (TypeError, ValueError): - return cls._DROP_VALUE - return value - - @classmethod - def _sanitize_extras(cls, extras: dict[str, Any]) -> dict[str, Any]: - sanitized: dict[str, Any] = {} - for key, value in extras.items(): - normalized = cls._sanitize_extra_value(value) - if normalized is not cls._DROP_VALUE: - sanitized[str(key)] = normalized - return sanitized - - @staticmethod - def core_to_sdk( - event: AstrMessageEvent, - *, - dispatch_token: str, - plugin_id: str, - request_id: str, - ) -> dict[str, Any]: - message_type = event.get_message_type() - raw = { - "dispatch_token": dispatch_token, - "plugin_id": plugin_id, - "request_id": request_id, - "platform_id": event.get_platform_id(), - } - payload: dict[str, Any] = { - "text": event.get_message_str(), - "user_id": event.get_sender_id(), - "group_id": event.get_group_id() or None, - "platform": event.get_platform_name(), - "platform_id": event.get_platform_id(), - "session_id": event.unified_msg_origin, - "self_id": event.get_self_id(), - "message_type": getattr(message_type, "value", None), - "sender_name": event.get_sender_name(), - "is_admin": event.is_admin(), - "is_wake": event.is_wake, - "is_at_or_wake_command": event.is_at_or_wake_command, - "message_outline": event.get_message_outline(), - "raw": raw, - "target": { - "conversation_id": event.unified_msg_origin, - "platform": event.get_platform_name(), - "raw": raw, - }, - } - extras = event.get_extra() - if isinstance(extras, dict) and extras: - sanitized_extras = EventConverter._sanitize_extras(extras) - if sanitized_extras: - payload["extras"] = sanitized_extras - messages = [] - for component in event.get_messages(): - try: - messages.append(component_to_payload_sync(component)) - except Exception: - messages.append( - { - "type": "unknown", - "data": {"value": str(component)}, - } - ) - if messages: - payload["messages"] = messages - return payload - - @staticmethod - def extract_handler_result(sdk_result: dict[str, Any] | None) -> dict[str, Any]: - if not sdk_result: - return {"sent_message": False, "stop": False, "call_llm": False} - return { - "sent_message": bool(sdk_result.get("sent_message", False)), - "stop": bool(sdk_result.get("stop", False)), - "call_llm": bool(sdk_result.get("call_llm", False)), - } diff --git a/astrbot/core/sdk_bridge/plugin_bridge.py b/astrbot/core/sdk_bridge/plugin_bridge.py deleted file mode 100644 index cc5bb8a47..000000000 --- a/astrbot/core/sdk_bridge/plugin_bridge.py +++ /dev/null @@ -1,3513 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import json -import os -import re -import signal -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.llm.agents import AgentSpec -from astrbot_sdk.llm.entities import LLMToolSpec -from astrbot_sdk.message.components import component_to_payload_sync -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - CompositeFilterSpec, - EventTrigger, - HandlerDescriptor, - MessageTrigger, - PlatformFilterSpec, - ScheduleTrigger, -) -from astrbot_sdk.runtime.loader import ( - PluginDiscoveryIssue, - PluginEnvironmentManager, - PluginSpec, - discover_plugins, - load_plugin_config, - load_plugin_config_schema, - save_plugin_config, -) -from astrbot_sdk.runtime.supervisor import WorkerSession -from quart import request as quart_request - -from astrbot.core import logger -from astrbot.core.agent.mcp_client import MCPClient -from astrbot.core.message.message_event_result import MessageChain, MessageEventResult -from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.provider.entities import LLMResponse as CoreLLMResponse -from astrbot.core.provider.entities import ProviderRequest as CoreProviderRequest -from astrbot.core.skills.skill_manager import ( - SkillManager, - _parse_frontmatter_description, -) -from astrbot.core.utils.astrbot_path import ( - get_astrbot_data_path, - get_astrbot_plugin_data_path, -) - -from .bridge_base import _build_message_chain_from_payload -from .capability_bridge import CoreCapabilityBridge -from .event_converter import EventConverter -from .trigger_converter import TriggerConverter, TriggerMatch - -SDK_STATE_ENABLED = "enabled" -SDK_STATE_DISABLED = "disabled" -SDK_STATE_RELOADING = "reloading" -SDK_STATE_FAILED = "failed" -SDK_STATE_UNSUPPORTED_PARTIAL = "unsupported_partial" - -SKIP_LEGACY_STOPPED = "legacy_stopped" -SKIP_LEGACY_REPLIED = "legacy_replied" -SKIP_SDK_RELOADING = "sdk_reloading" -SKIP_NO_MATCH = "no_match" -SKIP_WORKER_FAILED = "worker_failed" -OVERLAY_TIMEOUT_SECONDS = 300 -SDK_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") -SUPPORTED_SYSTEM_EVENTS = { - "astrbot_loaded", - "platform_loaded", - "after_message_sent", - "waiting_llm_request", - "llm_request", - "llm_response", - "decorating_result", - "calling_func_tool", - "using_llm_tool", - "llm_tool_respond", - "plugin_error", - "plugin_loaded", - "plugin_unloaded", -} - - -@dataclass(slots=True) -class SdkHandlerRef: - descriptor: HandlerDescriptor - declaration_order: int - - @property - def handler_id(self) -> str: - return self.descriptor.id - - @property - def handler_name(self) -> str: - return self.descriptor.id.rsplit(".", 1)[-1] - - -@dataclass(slots=True) -class SdkDispatchResult: - matched_handlers: list[dict[str, str]] = field(default_factory=list) - executed_handlers: list[dict[str, str]] = field(default_factory=list) - sent_message: bool = False - stopped: bool = False - skipped_reason: str | None = None - - -@dataclass(slots=True) -class _DispatchState: - event: AstrMessageEvent - sent_message: bool = False - stopped: bool = False - - -@dataclass(slots=True) -class _RequestContext: - plugin_id: str - request_id: str - dispatch_token: str - dispatch_state: _DispatchState | None - cancelled: bool = False - - @property - def has_event(self) -> bool: - return self.dispatch_state is not None - - @property - def event(self) -> AstrMessageEvent: - if self.dispatch_state is None: - raise AstrBotError.invalid_input( - "The current SDK request is not bound to a message event" - ) - return self.dispatch_state.event - - -@dataclass(slots=True) -class _InFlightRequest: - request_id: str - dispatch_token: str - task: asyncio.Task[dict[str, Any]] - logical_cancelled: bool = False - - -@dataclass(slots=True) -class _LocalMCPServerRuntime: - name: str - config: dict[str, Any] - active: bool - running: bool = False - client: MCPClient | None = None - tools: list[str] = field(default_factory=list) - tool_specs: list[LLMToolSpec] = field(default_factory=list) - errlogs: list[str] = field(default_factory=list) - last_error: str | None = None - ready_event: asyncio.Event = field(default_factory=asyncio.Event) - connect_task: asyncio.Task[None] | None = None - lease_path: Path | None = None - - -@dataclass(slots=True) -class _TemporaryMCPSessionRuntime: - plugin_id: str - name: str - client: MCPClient - tools: list[str] - - -@dataclass(slots=True) -class _RequestOverlayState: - dispatch_token: str - should_call_llm: bool - requested_llm: bool = False - sdk_local_extras: dict[str, Any] = field(default_factory=dict) - result_payload: dict[str, Any] | None = None - result_object: MessageEventResult | None = None - result_is_set: bool = False - handler_whitelist: set[str] | None = None - request_scope_ids: set[str] = field(default_factory=set) - closed: bool = False - cleanup_task: asyncio.Task[None] | None = None - - -@dataclass(slots=True) -class SdkPluginRecord: - plugin: PluginSpec - load_order: int - state: str - unsupported_features: list[str] - config_schema: dict[str, Any] - config: dict[str, Any] - handlers: list[SdkHandlerRef] - llm_tools: dict[str, LLMToolSpec] = field(default_factory=dict) - active_llm_tools: set[str] = field(default_factory=set) - agents: dict[str, AgentSpec] = field(default_factory=dict) - skills: dict[str, SdkRegisteredSkill] = field(default_factory=dict) - dynamic_command_routes: list[SdkDynamicCommandRoute] = field(default_factory=list) - session: WorkerSession | None = None - restart_attempted: bool = False - failure_reason: str = "" - issues: list[dict[str, Any]] = field(default_factory=list) - local_mcp_servers: dict[str, _LocalMCPServerRuntime] = field(default_factory=dict) - acknowledge_global_mcp_risk: bool = False - - @property - def plugin_id(self) -> str: - return self.plugin.name - - -@dataclass(slots=True) -class SdkHttpRoute: - plugin_id: str - route: str - methods: tuple[str, ...] - handler_capability: str - description: str - - -@dataclass(slots=True) -class SdkRegisteredSkill: - name: str - description: str - skill_dir: Path - skill_md_path: Path - - def to_registry_payload(self) -> dict[str, str]: - return { - "name": self.name, - "description": self.description, - "path": str(self.skill_md_path), - "skill_dir": str(self.skill_dir), - } - - -@dataclass(slots=True) -class SdkDynamicCommandRoute: - command_name: str - handler_full_name: str - desc: str - priority: int - use_regex: bool - declaration_order: int - - -class SdkPluginBridge: - _DROP_VALUE = object() - - def __init__(self, star_context) -> None: - self.star_context = star_context - self.plugins_dir = Path(get_astrbot_data_path()) / "sdk_plugins" - self.state_path = Path(get_astrbot_data_path()) / "sdk_plugins_state.json" - self.plugins_dir.mkdir(parents=True, exist_ok=True) - self._started = False - self._stopping = False - self._state_overrides = self._load_state_overrides() - self.env_manager = PluginEnvironmentManager(Path(__file__).resolve().parents[3]) - self.capability_bridge = CoreCapabilityBridge( - star_context=star_context, - plugin_bridge=self, - ) - self._records: dict[str, SdkPluginRecord] = {} - self._request_contexts: dict[str, _RequestContext] = {} - self._request_id_to_token: dict[str, str] = {} - self._request_plugin_ids: dict[str, str] = {} - self._request_overlays: dict[str, _RequestOverlayState] = {} - self._plugin_requests: dict[str, dict[str, _InFlightRequest]] = {} - self._http_routes: dict[str, list[SdkHttpRoute]] = {} - self._session_waiters: dict[str, set[str]] = {} - self._schedule_job_ids: dict[str, set[str]] = {} - self._discovery_issues: dict[str, list[dict[str, Any]]] = {} - self._temporary_mcp_sessions: dict[str, _TemporaryMCPSessionRuntime] = {} - - async def start(self) -> None: - if self._started: - return - self._sweep_stale_mcp_leases() - await self.reload_all(reset_restart_budget=True) - self._started = True - - async def stop(self) -> None: - if not self._started and not self._records: - return - self._stopping = True - for plugin_id in list(self._records.keys()): - await self._cancel_plugin_requests(plugin_id) - await self._close_temporary_mcp_sessions(plugin_id) - for record in list(self._records.values()): - await self._shutdown_local_mcp_servers(record) - if record.session is not None: - await record.session.stop() - record.session = None - self._records.clear() - self._request_contexts.clear() - self._request_id_to_token.clear() - self._request_plugin_ids.clear() - for overlay in list(self._request_overlays.values()): - if overlay.cleanup_task is not None: - overlay.cleanup_task.cancel() - self._request_overlays.clear() - self._plugin_requests.clear() - self._http_routes.clear() - self._session_waiters.clear() - self._schedule_job_ids.clear() - self._temporary_mcp_sessions.clear() - self._started = False - self._stopping = False - - async def reload_all(self, *, reset_restart_budget: bool = False) -> None: - discovered = discover_plugins(self.plugins_dir) - self._set_discovery_issues(discovered.issues) - self.env_manager.plan(discovered.plugins) - known = {plugin.name for plugin in discovered.plugins} - SkillManager().prune_sdk_plugin_skills(known) - for plugin_id in list(self._records.keys()): - if plugin_id not in known: - await self._teardown_plugin(plugin_id) - self._records.pop(plugin_id, None) - for load_order, plugin in enumerate(discovered.plugins): - await self._load_or_reload_plugin( - plugin, - load_order=load_order, - reset_restart_budget=reset_restart_budget, - ) - - async def reload_plugin(self, plugin_id: str) -> None: - discovered = discover_plugins(self.plugins_dir) - self._set_discovery_issues(discovered.issues) - self.env_manager.plan(discovered.plugins) - for load_order, plugin in enumerate(discovered.plugins): - if plugin.name != plugin_id: - continue - await self._load_or_reload_plugin( - plugin, - load_order=load_order, - reset_restart_budget=True, - ) - return - raise ValueError(f"SDK plugin not found: {plugin_id}") - - async def turn_off_plugin(self, plugin_id: str) -> None: - record = self._records.get(plugin_id) - if record is None: - raise ValueError(f"SDK plugin not found: {plugin_id}") - record.state = SDK_STATE_DISABLED - await self._cancel_plugin_requests(plugin_id) - await self._teardown_plugin(plugin_id) - record.failure_reason = "" - self._set_disabled_override(plugin_id, disabled=True) - - async def turn_on_plugin(self, plugin_id: str) -> None: - discovered = discover_plugins(self.plugins_dir) - self._set_discovery_issues(discovered.issues) - self.env_manager.plan(discovered.plugins) - for load_order, plugin in enumerate(discovered.plugins): - if plugin.name != plugin_id: - continue - self._set_disabled_override(plugin_id, disabled=False) - await self._load_or_reload_plugin( - plugin, - load_order=load_order, - reset_restart_budget=True, - ) - return - raise ValueError(f"SDK plugin not found: {plugin_id}") - - def list_plugins(self) -> list[dict[str, Any]]: - records = sorted(self._records.values(), key=lambda item: item.load_order) - items = [self._record_to_dashboard_item(record) for record in records] - for plugin_id, issues in sorted(self._discovery_issues.items()): - if plugin_id in self._records: - continue - items.append(self._failed_issue_to_dashboard_item(plugin_id, issues)) - return items - - def get_plugin_metadata(self, plugin_id: str) -> dict[str, Any] | None: - record = self._records.get(plugin_id) - if record is not None: - manifest = record.plugin.manifest_data - support_platforms = manifest.get("support_platforms") - return { - "name": plugin_id, - "display_name": str(manifest.get("display_name") or plugin_id), - "description": str( - manifest.get("desc") or manifest.get("description") or "" - ), - "author": str(manifest.get("author") or ""), - "version": str(manifest.get("version") or "0.0.0"), - "enabled": record.state not in {SDK_STATE_DISABLED, SDK_STATE_FAILED}, - "support_platforms": [ - str(item) for item in support_platforms if isinstance(item, str) - ] - if isinstance(support_platforms, list) - else [], - "astrbot_version": ( - str(manifest.get("astrbot_version")) - if manifest.get("astrbot_version") is not None - else None - ), - "runtime_kind": "sdk", - "issues": [dict(item) for item in record.issues], - } - for plugin in self.star_context.get_all_stars(): - if plugin.name == plugin_id: - return { - "name": plugin.name, - "display_name": plugin.display_name, - "description": plugin.desc, - "author": plugin.author, - "version": plugin.version, - "enabled": plugin.activated, - "support_platforms": list(plugin.support_platforms), - "astrbot_version": plugin.astrbot_version, - "runtime_kind": "legacy", - } - if plugin_id in self._discovery_issues: - issue = self._discovery_issues[plugin_id][0] - return { - "name": plugin_id, - "display_name": plugin_id, - "description": str(issue.get("message", "")), - "author": "", - "version": "0.0.0", - "enabled": False, - "support_platforms": [], - "astrbot_version": None, - "runtime_kind": "sdk", - "issues": [dict(item) for item in self._discovery_issues[plugin_id]], - } - return None - - def list_plugin_metadata(self) -> list[dict[str, Any]]: - metadata = [] - for plugin in self.star_context.get_all_stars(): - metadata.append( - { - "name": plugin.name, - "display_name": plugin.display_name, - "description": plugin.desc, - "author": plugin.author, - "version": plugin.version, - "enabled": plugin.activated, - "support_platforms": list(plugin.support_platforms), - "astrbot_version": plugin.astrbot_version, - "runtime_kind": "legacy", - } - ) - for plugin_id in sorted(self._records.keys()): - plugin_metadata = self.get_plugin_metadata(plugin_id) - if plugin_metadata is not None: - metadata.append(plugin_metadata) - for plugin_id in sorted(self._discovery_issues.keys()): - if plugin_id in self._records: - continue - plugin_metadata = self.get_plugin_metadata(plugin_id) - if plugin_metadata is not None: - metadata.append(plugin_metadata) - return metadata - - def get_plugin_config(self, plugin_id: str) -> dict[str, Any] | None: - record = self._records.get(plugin_id) - if record is None: - return None - return dict(record.config) - - def get_plugin_config_schema(self, plugin_id: str) -> dict[str, Any] | None: - record = self._records.get(plugin_id) - if record is None: - return None - return dict(record.config_schema) - - def save_plugin_config( - self, - plugin_id: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - record = self._records.get(plugin_id) - if record is None: - raise ValueError(f"SDK plugin not found: {plugin_id}") - normalized = save_plugin_config( - record.plugin, - payload, - schema=record.config_schema, - ) - record.config = dict(normalized) - return dict(record.config) - - def get_registered_llm_tools(self, plugin_id: str) -> list[LLMToolSpec]: - record = self._records.get(plugin_id) - if record is None: - return [] - return [item.model_copy(deep=True) for item in record.llm_tools.values()] - - def get_active_llm_tools(self, plugin_id: str) -> list[LLMToolSpec]: - record = self._records.get(plugin_id) - if record is None: - return [] - return [ - item.model_copy(deep=True) - for name, item in record.llm_tools.items() - if name in record.active_llm_tools - ] - - def get_llm_tool(self, plugin_id: str, name: str) -> LLMToolSpec | None: - record = self._records.get(plugin_id) - if record is None: - return None - spec = record.llm_tools.get(name) - if spec is None: - return None - return spec.model_copy(deep=True) - - def add_llm_tools(self, plugin_id: str, tools: list[LLMToolSpec]) -> list[str]: - record = self._records.get(plugin_id) - if record is None: - return [] - names: list[str] = [] - for spec in tools: - record.llm_tools[spec.name] = spec.model_copy(deep=True) - if spec.active: - record.active_llm_tools.add(spec.name) - else: - record.active_llm_tools.discard(spec.name) - names.append(spec.name) - return names - - def remove_llm_tool(self, plugin_id: str, name: str) -> bool: - record = self._records.get(plugin_id) - if record is None: - return False - removed = record.llm_tools.pop(name, None) is not None - record.active_llm_tools.discard(name) - return removed - - def activate_llm_tool(self, plugin_id: str, name: str) -> bool: - record = self._records.get(plugin_id) - if record is None: - return False - spec = record.llm_tools.get(name) - if spec is None: - return False - spec.active = True - record.active_llm_tools.add(name) - return True - - def deactivate_llm_tool(self, plugin_id: str, name: str) -> bool: - record = self._records.get(plugin_id) - if record is None: - return False - spec = record.llm_tools.get(name) - if spec is None: - return False - spec.active = False - record.active_llm_tools.discard(name) - return True - - def _local_mcp_record( - self, plugin_id: str, name: str - ) -> _LocalMCPServerRuntime | None: - record = self._records.get(plugin_id) - if record is None: - return None - return record.local_mcp_servers.get(name) - - @staticmethod - def _serialize_local_mcp_server( - runtime: _LocalMCPServerRuntime, - ) -> dict[str, Any]: - errlogs = list(runtime.errlogs) - if runtime.client is not None: - errlogs.extend(str(item) for item in runtime.client.server_errlogs) - return { - "name": runtime.name, - "scope": "local", - "active": runtime.active, - "running": runtime.running, - "config": dict(runtime.config), - "tools": list(runtime.tools), - "errlogs": errlogs, - "last_error": runtime.last_error, - } - - def get_local_mcp_server( - self, - plugin_id: str, - name: str, - ) -> dict[str, Any] | None: - runtime = self._local_mcp_record(plugin_id, name) - if runtime is None: - return None - return self._serialize_local_mcp_server(runtime) - - def list_local_mcp_servers(self, plugin_id: str) -> list[dict[str, Any]]: - record = self._records.get(plugin_id) - if record is None: - return [] - return [ - self._serialize_local_mcp_server(runtime) - for runtime in sorted( - record.local_mcp_servers.values(), - key=lambda item: item.name, - ) - ] - - def get_request_tool_specs(self, plugin_id: str) -> list[LLMToolSpec]: - record = self._records.get(plugin_id) - if record is None: - return [] - specs: dict[str, LLMToolSpec] = { - item.name: item.model_copy(deep=True) - for name, item in record.llm_tools.items() - if name in record.active_llm_tools - } - for runtime in record.local_mcp_servers.values(): - if not runtime.active or not runtime.running: - continue - for spec in runtime.tool_specs: - specs.setdefault(spec.name, spec.model_copy(deep=True)) - return list(specs.values()) - - def get_registered_agents(self, plugin_id: str) -> list[AgentSpec]: - record = self._records.get(plugin_id) - if record is None: - return [] - return [item.model_copy(deep=True) for item in record.agents.values()] - - def get_registered_agent(self, plugin_id: str, name: str) -> AgentSpec | None: - record = self._records.get(plugin_id) - if record is None: - return None - spec = record.agents.get(name) - if spec is None: - return None - return spec.model_copy(deep=True) - - def register_dynamic_command_route( - self, - *, - plugin_id: str, - command_name: str, - handler_full_name: str, - desc: str = "", - priority: int = 0, - use_regex: bool = False, - ) -> None: - record = self._records.get(plugin_id) - if record is None: - raise AstrBotError.invalid_input(f"Unknown SDK plugin: {plugin_id}") - if isinstance(priority, bool) or not isinstance(priority, int): - raise AstrBotError.invalid_input("priority must be an integer") - command_text = str(command_name).strip() - if not command_text: - raise AstrBotError.invalid_input("command_name must not be empty") - handler_text = str(handler_full_name).strip() - if not handler_text: - raise AstrBotError.invalid_input("handler_full_name must not be empty") - if not handler_text.startswith(f"{plugin_id}:"): - raise AstrBotError.invalid_input( - "handler_full_name must belong to the caller plugin" - ) - if self._find_handler_ref(record, handler_text) is None: - raise AstrBotError.invalid_input( - f"Unknown handler_full_name for plugin '{plugin_id}': {handler_text}" - ) - existing_order = next( - ( - route.declaration_order - for route in record.dynamic_command_routes - if route.command_name == command_text - and route.use_regex is bool(use_regex) - ), - len(record.dynamic_command_routes), - ) - updated = [ - route - for route in record.dynamic_command_routes - if not ( - route.command_name == command_text - and route.use_regex is bool(use_regex) - ) - ] - updated.append( - SdkDynamicCommandRoute( - command_name=command_text, - handler_full_name=handler_text, - desc=str(desc), - priority=priority, - use_regex=bool(use_regex), - declaration_order=existing_order, - ) - ) - updated.sort(key=lambda item: item.declaration_order) - record.dynamic_command_routes = updated - - def register_skill( - self, - *, - plugin_id: str, - name: str, - path: str, - description: str = "", - ) -> dict[str, str]: - record = self._records.get(plugin_id) - if record is None: - raise AstrBotError.invalid_input(f"Unknown SDK plugin: {plugin_id}") - - skill_name = str(name).strip() - if not skill_name or not SDK_SKILL_NAME_RE.fullmatch(skill_name): - raise AstrBotError.invalid_input( - "skill.register requires a name matching [A-Za-z0-9._-]+" - ) - - path_text = str(path).strip() - if not path_text: - raise AstrBotError.invalid_input("skill.register requires path") - - plugin_root = record.plugin.plugin_dir.resolve() - requested_path = Path(path_text) - resolved_path = ( - requested_path.resolve() - if requested_path.is_absolute() - else (plugin_root / requested_path).resolve() - ) - - skill_dir = resolved_path if resolved_path.is_dir() else resolved_path.parent - skill_md_path = ( - resolved_path / "SKILL.md" if resolved_path.is_dir() else resolved_path - ) - if skill_md_path.name != "SKILL.md" or not skill_md_path.is_file(): - raise AstrBotError.invalid_input( - "skill.register path must point to a skill directory containing SKILL.md or to SKILL.md itself" - ) - if not skill_dir.is_dir(): - raise AstrBotError.invalid_input( - "skill.register resolved skill_dir is not a directory" - ) - if not skill_md_path.is_relative_to(plugin_root): - raise AstrBotError.invalid_input( - "skill.register path must stay inside the plugin directory" - ) - - normalized_description = str(description).strip() - if not normalized_description: - try: - normalized_description = _parse_frontmatter_description( - skill_md_path.read_text(encoding="utf-8") - ) - except Exception: - normalized_description = "" - - record.skills[skill_name] = SdkRegisteredSkill( - name=skill_name, - description=normalized_description, - skill_dir=skill_dir, - skill_md_path=skill_md_path, - ) - self._publish_plugin_skills(plugin_id) - return { - "name": skill_name, - "description": normalized_description, - "path": str(skill_md_path), - "skill_dir": str(skill_dir), - } - - def unregister_skill(self, *, plugin_id: str, name: str) -> bool: - record = self._records.get(plugin_id) - if record is None: - raise AstrBotError.invalid_input(f"Unknown SDK plugin: {plugin_id}") - removed = record.skills.pop(str(name).strip(), None) is not None - if removed: - self._publish_plugin_skills(plugin_id) - return removed - - def list_registered_skills(self, plugin_id: str) -> list[dict[str, str]]: - record = self._records.get(plugin_id) - if record is None: - return [] - return [ - record.skills[name].to_registry_payload() - for name in sorted(record.skills.keys()) - ] - - def _publish_plugin_skills(self, plugin_id: str) -> None: - record = self._records.get(plugin_id) - manager = SkillManager() - if record is None or not record.skills: - manager.remove_sdk_plugin_skills(plugin_id) - return - manager.replace_sdk_plugin_skills( - plugin_id, - [skill.to_registry_payload() for skill in record.skills.values()], - ) - - def register_http_api( - self, - *, - plugin_id: str, - route: str, - methods: list[str], - handler_capability: str, - description: str, - ) -> None: - normalized_route = self._normalize_http_route(route) - normalized_methods = self._normalize_http_methods(methods) - if not handler_capability: - raise AstrBotError.invalid_input( - "http.register_api requires handler_capability" - ) - self._ensure_http_route_available( - plugin_id=plugin_id, - route=normalized_route, - methods=normalized_methods, - ) - route_entry = SdkHttpRoute( - plugin_id=plugin_id, - route=normalized_route, - methods=normalized_methods, - handler_capability=handler_capability, - description=description, - ) - plugin_routes = [ - entry - for entry in self._http_routes.get(plugin_id, []) - if not ( - entry.route == normalized_route and entry.methods == normalized_methods - ) - ] - plugin_routes.append(route_entry) - self._http_routes[plugin_id] = plugin_routes - - def unregister_http_api( - self, - *, - plugin_id: str, - route: str, - methods: list[str], - ) -> None: - normalized_route = self._normalize_http_route(route) - normalized_methods = {method.upper() for method in methods if method} - updated: list[SdkHttpRoute] = [] - for entry in self._http_routes.get(plugin_id, []): - if entry.route != normalized_route: - updated.append(entry) - continue - if not normalized_methods: - # Plugins do not have a separate "delete route" capability, so an - # empty method list means "remove every method registered on route". - continue - remaining = tuple( - method for method in entry.methods if method not in normalized_methods - ) - if remaining: - updated.append( - SdkHttpRoute( - plugin_id=entry.plugin_id, - route=entry.route, - methods=remaining, - handler_capability=entry.handler_capability, - description=entry.description, - ) - ) - if updated: - self._http_routes[plugin_id] = updated - else: - self._http_routes.pop(plugin_id, None) - - def list_http_apis(self, plugin_id: str) -> list[dict[str, Any]]: - return [ - { - "route": entry.route, - "methods": list(entry.methods), - "handler_capability": entry.handler_capability, - "description": entry.description, - } - for entry in self._http_routes.get(plugin_id, []) - ] - - async def dispatch_http_request( - self, - route: str, - method: str, - ) -> dict[str, Any] | None: - resolved = self._resolve_http_route(route, method) - if resolved is None: - return None - record, route_entry = resolved - if record.session is None: - raise AstrBotError.invalid_input("SDK HTTP route worker is unavailable") - text_body = await quart_request.get_data(as_text=True) - payload = { - "method": method.upper(), - "route": route_entry.route, - "path": quart_request.path, - "query": quart_request.args.to_dict(flat=False), - "headers": dict(quart_request.headers), - "json_body": await quart_request.get_json(silent=True), - "text_body": text_body, - } - output = await record.session.invoke_capability( - route_entry.handler_capability, - payload, - request_id=f"sdk_http_{record.plugin_id}_{uuid.uuid4().hex}", - ) - if not isinstance(output, dict): - raise AstrBotError.invalid_input("SDK HTTP handler must return an object") - return output - - def register_session_waiter(self, *, plugin_id: str, session_key: str) -> None: - if not session_key: - raise AstrBotError.invalid_input( - "session waiter registration requires session_key" - ) - self._session_waiters.setdefault(plugin_id, set()).add(session_key) - - def unregister_session_waiter(self, *, plugin_id: str, session_key: str) -> None: - plugin_waiters = self._session_waiters.get(plugin_id) - if plugin_waiters is None: - return - plugin_waiters.discard(session_key) - if not plugin_waiters: - self._session_waiters.pop(plugin_id, None) - - async def dispatch_message(self, event: AstrMessageEvent) -> SdkDispatchResult: - result = SdkDispatchResult() - if event.is_stopped(): - result.skipped_reason = SKIP_LEGACY_STOPPED - return result - if self._legacy_has_replied(event): - result.skipped_reason = SKIP_LEGACY_REPLIED - return result - - waiter_plugins = self._match_waiter_plugins(event.unified_msg_origin) - if waiter_plugins: - return await self._dispatch_waiter_event(event, waiter_plugins) - - dispatch_token = self._get_dispatch_token(event) or uuid.uuid4().hex - self._bind_dispatch_token(event, dispatch_token) - overlay = self._ensure_request_overlay( - dispatch_token, - should_call_llm=not bool(getattr(event, "call_llm", False)), - ) - matches = self._match_handlers(event) - if not matches: - result.skipped_reason = SKIP_NO_MATCH - return result - result.matched_handlers = [ - {"plugin_id": match.plugin_id, "handler_id": match.handler_id} - for match in matches - ] - - dispatch_state = _DispatchState(event=event) - request_context = self._request_contexts.get(dispatch_token) - if request_context is None: - request_context = _RequestContext( - plugin_id="", - request_id="", - dispatch_token=dispatch_token, - dispatch_state=dispatch_state, - ) - self._request_contexts[dispatch_token] = request_context - else: - request_context.dispatch_state = dispatch_state - skipped_reason = None - for match in matches: - whitelist = ( - None - if overlay.handler_whitelist is None - else set(overlay.handler_whitelist) - ) - if whitelist is not None and match.plugin_id not in whitelist: - continue - record = self._records.get(match.plugin_id) - if record is None: - continue - if record.state == SDK_STATE_RELOADING: - skipped_reason = skipped_reason or SKIP_SDK_RELOADING - continue - if ( - record.state in {SDK_STATE_FAILED, SDK_STATE_DISABLED} - or record.session is None - ): - skipped_reason = skipped_reason or SKIP_WORKER_FAILED - continue - - request_id = f"sdk_{record.plugin_id}_{uuid.uuid4().hex}" - request_context.plugin_id = record.plugin_id - request_context.request_id = request_id - request_context.cancelled = False - setattr(event, "_sdk_last_request_id", request_id) - payload = EventConverter.core_to_sdk( - event, - dispatch_token=dispatch_token, - plugin_id=record.plugin_id, - request_id=request_id, - ) - self._apply_request_scoped_event_payload(payload, overlay) - task = asyncio.create_task( - record.session.invoke_handler( - match.handler_id, - payload, - request_id=request_id, - args=match.args, - ) - ) - self._track_request_scope( - dispatch_token=dispatch_token, - request_id=request_id, - plugin_id=record.plugin_id, - ) - self._plugin_requests.setdefault(record.plugin_id, {})[request_id] = ( - _InFlightRequest( - request_id=request_id, - dispatch_token=dispatch_token, - task=task, - ) - ) - - try: - output = await task - except asyncio.CancelledError: - raise - except Exception as exc: - logger.warning( - "SDK handler failed: plugin=%s handler=%s error=%s", - record.plugin_id, - match.handler_id, - exc, - ) - skipped_reason = skipped_reason or SKIP_WORKER_FAILED - output = {} - finally: - inflight = self._plugin_requests.get(record.plugin_id, {}).pop( - request_id, - None, - ) - - if inflight is not None and inflight.logical_cancelled: - continue - - handler_result = EventConverter.extract_handler_result( - output if isinstance(output, dict) else {} - ) - if isinstance(output, dict) and "sdk_local_extras" in output: - self._persist_sdk_local_extras_from_handler( - overlay, - output.get("sdk_local_extras"), - plugin_id=record.plugin_id, - handler_id=match.handler_id, - ) - result.executed_handlers.append( - {"plugin_id": record.plugin_id, "handler_id": match.handler_id} - ) - dispatch_state.sent_message = ( - dispatch_state.sent_message or handler_result["sent_message"] - ) - dispatch_state.stopped = dispatch_state.stopped or handler_result["stop"] - if handler_result["call_llm"]: - overlay.requested_llm = True - overlay.should_call_llm = True - if handler_result["sent_message"] or handler_result["stop"]: - overlay.should_call_llm = False - if handler_result["stop"]: - break - - result.sent_message = dispatch_state.sent_message - result.stopped = dispatch_state.stopped - if not result.executed_handlers: - result.skipped_reason = skipped_reason or SKIP_NO_MATCH - if result.sent_message: - event._has_send_oper = True - overlay.should_call_llm = False - event.should_call_llm(True) - if result.stopped: - event.stop_event() - overlay.should_call_llm = False - event.should_call_llm(True) - return result - - def resolve_request_plugin_id(self, request_id: str) -> str: - plugin_id = self._request_plugin_ids.get(request_id) - if plugin_id is not None: - return plugin_id - token = self._request_id_to_token.get(request_id) - if token is not None and token in self._request_contexts: - return self._request_contexts[token].plugin_id - raise AstrBotError.invalid_input(f"Unknown SDK request id: {request_id}") - - def resolve_request_session(self, request_id: str) -> _RequestContext | None: - token = self._request_id_to_token.get(request_id) - if token is None: - return None - return self._request_contexts.get(token) - - def get_request_context_by_token( - self, dispatch_token: str - ) -> _RequestContext | None: - return self._request_contexts.get(dispatch_token) - - def _bind_dispatch_token( - self, event: AstrMessageEvent, dispatch_token: str - ) -> None: - setattr(event, "_sdk_dispatch_token", dispatch_token) - - def _get_dispatch_token(self, event: AstrMessageEvent) -> str | None: - token = getattr(event, "_sdk_dispatch_token", None) - return str(token) if token else None - - def _schedule_overlay_cleanup( - self, dispatch_token: str - ) -> asyncio.Task[None] | None: - async def _cleanup_later() -> None: - try: - await asyncio.sleep(OVERLAY_TIMEOUT_SECONDS) - except asyncio.CancelledError: - return - self._close_request_overlay(dispatch_token) - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return None - return loop.create_task(_cleanup_later()) - - def _ensure_request_overlay( - self, - dispatch_token: str, - *, - should_call_llm: bool, - ) -> _RequestOverlayState: - overlay = self._request_overlays.get(dispatch_token) - if overlay is not None: - if overlay.closed: - overlay.closed = False - if overlay.cleanup_task is None or overlay.cleanup_task.done(): - overlay.cleanup_task = self._schedule_overlay_cleanup(dispatch_token) - return overlay - overlay = _RequestOverlayState( - dispatch_token=dispatch_token, - should_call_llm=should_call_llm, - cleanup_task=self._schedule_overlay_cleanup(dispatch_token), - ) - self._request_overlays[dispatch_token] = overlay - return overlay - - def _track_request_scope( - self, - *, - dispatch_token: str, - request_id: str, - plugin_id: str, - ) -> None: - # request-scoped system.event.* calls may outlive the original handler RPC - # when plugin code moves follow-up work into background tasks. - self._request_id_to_token[request_id] = dispatch_token - self._request_plugin_ids[request_id] = plugin_id - overlay = self._request_overlays.get(dispatch_token) - if overlay is not None: - overlay.request_scope_ids.add(request_id) - - def _close_request_overlay(self, dispatch_token: str) -> None: - overlay = self._request_overlays.pop(dispatch_token, None) - if overlay is not None: - overlay.closed = True - if overlay.cleanup_task is not None: - overlay.cleanup_task.cancel() - for request_id in overlay.request_scope_ids: - self._request_id_to_token.pop(request_id, None) - self._request_plugin_ids.pop(request_id, None) - request_context = self._request_contexts.pop(dispatch_token, None) - if request_context is not None: - request_context.cancelled = True - - def close_request_overlay_for_event(self, event: AstrMessageEvent) -> None: - dispatch_token = self._get_dispatch_token(event) - if not dispatch_token: - return - self._close_request_overlay(dispatch_token) - - def get_request_overlay_by_token( - self, dispatch_token: str - ) -> _RequestOverlayState | None: - overlay = self._request_overlays.get(dispatch_token) - if overlay is None or overlay.closed: - return None - return overlay - - def get_request_overlay_by_request_id( - self, request_id: str - ) -> _RequestOverlayState | None: - token = self._request_id_to_token.get(request_id) - if not token: - return None - return self.get_request_overlay_by_token(token) - - def request_llm_for_request(self, request_id: str) -> bool: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return False - overlay.requested_llm = True - overlay.should_call_llm = True - return True - - def get_effective_should_call_llm(self, event: AstrMessageEvent) -> bool: - dispatch_token = self._get_dispatch_token(event) - if dispatch_token: - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is not None: - return overlay.should_call_llm - return not bool(getattr(event, "call_llm", False)) - - def get_should_call_llm_for_request(self, request_id: str) -> bool | None: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return None - return overlay.should_call_llm - - def set_result_for_request( - self, - request_id: str, - result_payload: dict[str, Any] | None, - ) -> bool: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return False - if result_payload is None: - overlay.result_payload = None - overlay.result_object = None - else: - normalized_payload = json.loads(json.dumps(result_payload)) - overlay.result_payload = normalized_payload - chain_payload = normalized_payload.get("chain") - overlay.result_object = ( - self._build_core_result_from_chain_payload(chain_payload) - if isinstance(chain_payload, list) - else None - ) - overlay.result_is_set = True - return True - - def clear_result_for_request(self, request_id: str) -> bool: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return False - overlay.result_payload = None - overlay.result_object = None - overlay.result_is_set = True - return True - - def get_result_payload_for_request(self, request_id: str) -> dict[str, Any] | None: - overlay = self.get_request_overlay_by_request_id(request_id) - request_context = self.resolve_request_session(request_id) - request_context_has_event = False - if request_context is not None: - has_event = getattr(request_context, "has_event", None) - request_context_has_event = ( - bool(has_event) - if has_event is not None - else hasattr(request_context, "event") - ) - if overlay is not None and overlay.result_is_set: - if overlay.result_object is not None: - overlay.result_payload = self._legacy_result_to_sdk_payload( - overlay.result_object - ) - return ( - json.loads(json.dumps(overlay.result_payload)) - if overlay.result_payload is not None - else None - ) - if request_context is None or not request_context_has_event: - return None - return self._legacy_result_to_sdk_payload(request_context.event.get_result()) - - def set_handler_whitelist_for_request( - self, - request_id: str, - plugin_names: set[str] | None, - ) -> bool: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return False - overlay.handler_whitelist = None if plugin_names is None else set(plugin_names) - return True - - def get_handler_whitelist_for_request(self, request_id: str) -> set[str] | None: - overlay = self.get_request_overlay_by_request_id(request_id) - if overlay is None: - return None - return ( - None - if overlay.handler_whitelist is None - else set(overlay.handler_whitelist) - ) - - def _get_handler_whitelist_for_event( - self, event: AstrMessageEvent - ) -> set[str] | None: - dispatch_token = self._get_dispatch_token(event) - if not dispatch_token: - return None - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is None: - return None - return ( - None - if overlay.handler_whitelist is None - else set(overlay.handler_whitelist) - ) - - @staticmethod - def _build_core_message_chain_from_payload( - chain_payload: list[dict[str, Any]], - ) -> MessageChain: - return _build_message_chain_from_payload(chain_payload) - - @classmethod - def _build_core_result_from_chain_payload( - cls, - chain_payload: list[dict[str, Any]], - ) -> MessageEventResult: - chain = cls._build_core_message_chain_from_payload(chain_payload) - result = MessageEventResult() - # Core stages currently treat result.chain as a MessageChain-like object and - # call get_plain_text()/mutate nested components on it directly. - setattr(result, "chain", chain) - result.use_t2i_ = chain.use_t2i_ - result.type = chain.type - return result - - @staticmethod - def _legacy_result_to_sdk_payload( - result: MessageEventResult | None, - ) -> dict[str, Any] | None: - if result is None: - return None - chain = ( - result.chain.chain - if isinstance(result.chain, MessageChain) - else result.chain - ) - return { - "type": "chain" if chain else "empty", - "chain": SdkPluginBridge._components_to_sdk_payload(chain), - } - - @staticmethod - def _components_to_sdk_payload( - components: list[Any] | tuple[Any, ...] | None, - ) -> list[dict[str, Any]]: - return [ - component_to_payload_sync(component) for component in (components or []) - ] - - @classmethod - def _sanitize_sdk_extra_value(cls, value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, (list, tuple)): - items = [] - for item in value: - normalized = cls._sanitize_sdk_extra_value(item) - if normalized is not cls._DROP_VALUE: - items.append(normalized) - return items - if isinstance(value, dict): - normalized_dict: dict[str, Any] = {} - for key, item in value.items(): - normalized = cls._sanitize_sdk_extra_value(item) - if normalized is not cls._DROP_VALUE: - normalized_dict[str(key)] = normalized - return normalized_dict - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - try: - return cls._sanitize_sdk_extra_value(model_dump()) - except Exception: - return cls._DROP_VALUE - try: - json.dumps(value) - except (TypeError, ValueError): - return cls._DROP_VALUE - return value - - @classmethod - def _normalize_sdk_local_extras( - cls, - payload: Any, - ) -> tuple[dict[str, Any], list[str]]: - if not isinstance(payload, dict): - return {}, [] - normalized: dict[str, Any] = {} - dropped_keys: list[str] = [] - for key, value in payload.items(): - normalized_value = cls._sanitize_sdk_extra_value(value) - if normalized_value is cls._DROP_VALUE: - dropped_keys.append(str(key)) - continue - normalized[str(key)] = normalized_value - return normalized, dropped_keys - - @classmethod - def _apply_request_scoped_event_payload( - cls, - event_payload: dict[str, Any], - overlay: _RequestOverlayState, - ) -> None: - host_extras = ( - dict(event_payload["extras"]) - if isinstance(event_payload.get("extras"), dict) - else {} - ) - sdk_local_extras = dict(overlay.sdk_local_extras) - merged_extras = dict(host_extras) - merged_extras.update(sdk_local_extras) - event_payload["host_extras"] = host_extras - event_payload["sdk_local_extras"] = sdk_local_extras - event_payload["extras"] = merged_extras - - @classmethod - def _persist_sdk_local_extras_from_handler( - cls, - overlay: _RequestOverlayState, - payload: Any, - *, - plugin_id: str, - handler_id: str, - ) -> None: - if payload is None: - overlay.sdk_local_extras = {} - return - if not isinstance(payload, dict): - logger.warning( - "SDK event handler returned invalid sdk_local_extras: plugin=%s handler=%s payload_type=%s", - plugin_id, - handler_id, - type(payload).__name__, - ) - return - normalized, dropped_keys = cls._normalize_sdk_local_extras(payload) - overlay.sdk_local_extras = normalized - for key in dropped_keys: - logger.warning( - "Dropped non-serializable sdk_local_extras entry: plugin=%s handler=%s key=%s", - plugin_id, - handler_id, - key, - ) - - @staticmethod - def _core_provider_request_to_sdk_payload( - request: CoreProviderRequest, - ) -> dict[str, Any]: - tool_calls_result: list[dict[str, Any]] = [] - raw_results = request.tool_calls_result - if raw_results is not None: - if not isinstance(raw_results, list): - raw_results = [raw_results] - for item in raw_results: - if not getattr(item, "tool_calls_result", None): - continue - tool_name_by_id: dict[str, str] = {} - tool_calls_info = getattr(item, "tool_calls_info", None) - raw_tool_calls = getattr(tool_calls_info, "tool_calls", None) - if isinstance(raw_tool_calls, list): - for tool_call in raw_tool_calls: - if isinstance(tool_call, dict): - tool_call_id = tool_call.get("id") - function_payload = tool_call.get("function") - if isinstance(function_payload, dict): - tool_name = function_payload.get("name") - else: - tool_name = None - else: - tool_call_id = getattr(tool_call, "id", None) - function_payload = getattr(tool_call, "function", None) - tool_name = getattr(function_payload, "name", None) - if tool_call_id is None or tool_name is None: - continue - tool_name_by_id[str(tool_call_id)] = str(tool_name) - for tool_result in item.tool_calls_result: - tool_name = "" - tool_call_id = getattr(tool_result, "tool_call_id", None) - content = getattr(tool_result, "content", "") - success = True - if tool_call_id is not None: - tool_name = tool_name_by_id.get(str(tool_call_id), "") - tool_calls_result.append( - { - "tool_call_id": str(tool_call_id) - if tool_call_id is not None - else None, - "tool_name": tool_name, - "content": str(content or ""), - "success": bool(success), - } - ) - return { - "prompt": request.prompt, - "system_prompt": request.system_prompt or None, - "session_id": request.session_id or None, - "contexts": json.loads(json.dumps(request.contexts or [])), - "image_urls": list(request.image_urls or []), - "tool_calls_result": tool_calls_result, - "model": request.model, - } - - @staticmethod - def _apply_sdk_provider_request_payload( - request: CoreProviderRequest, - payload: dict[str, Any], - ) -> None: - prompt = payload.get("prompt") - request.prompt = None if prompt is None else str(prompt) - system_prompt = payload.get("system_prompt") - request.system_prompt = "" if system_prompt is None else str(system_prompt) - session_id = payload.get("session_id") - request.session_id = None if session_id is None else str(session_id) - - contexts = payload.get("contexts") - if isinstance(contexts, list): - request.contexts = json.loads(json.dumps(contexts)) - - image_urls = payload.get("image_urls") - if isinstance(image_urls, list): - request.image_urls = [str(item) for item in image_urls] - - model = payload.get("model") - request.model = None if model is None else str(model) - - @staticmethod - def _core_llm_response_to_sdk_payload( - response: CoreLLMResponse, - ) -> dict[str, Any]: - usage_payload = None - if response.usage is not None: - usage_payload = { - "input_tokens": response.usage.input, - "output_tokens": response.usage.output, - "total_tokens": response.usage.total, - "input_cached_tokens": response.usage.input_cached, - } - tool_calls: list[dict[str, Any]] = [] - for idx, tool_name in enumerate(response.tools_call_name): - tool_calls.append( - { - "id": ( - response.tools_call_ids[idx] - if idx < len(response.tools_call_ids) - else None - ), - "name": tool_name, - "arguments": ( - response.tools_call_args[idx] - if idx < len(response.tools_call_args) - else {} - ), - "extra_content": ( - response.tools_call_extra_content.get( - response.tools_call_ids[idx] - ) - if idx < len(response.tools_call_ids) - else None - ), - } - ) - return { - "text": response.completion_text or "", - "usage": usage_payload, - "finish_reason": "tool_calls" if tool_calls else "stop", - "tool_calls": tool_calls, - "role": response.role, - "reasoning_content": response.reasoning_content or None, - "reasoning_signature": response.reasoning_signature, - } - - @classmethod - def _apply_sdk_result_payload( - cls, - result: MessageEventResult, - payload: dict[str, Any], - ) -> MessageEventResult: - chain_payload = payload.get("chain") - updated = ( - cls._build_core_result_from_chain_payload(chain_payload) - if isinstance(chain_payload, list) - else MessageEventResult() - ) - result.chain = updated.chain - result.use_t2i_ = updated.use_t2i_ - result.type = updated.type - return result - - def get_effective_result( - self, event: AstrMessageEvent - ) -> MessageEventResult | None: - dispatch_token = self._get_dispatch_token(event) - if dispatch_token: - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is not None and overlay.result_is_set: - if overlay.result_payload is None: - return None - if overlay.result_object is None: - chain_payload = overlay.result_payload.get("chain") - if not isinstance(chain_payload, list): - return None - overlay.result_object = self._build_core_result_from_chain_payload( - chain_payload - ) - return overlay.result_object - return event.get_result() - - def before_platform_send(self, dispatch_token: str) -> None: - request_context = self._request_contexts.get(dispatch_token) - if request_context is None: - raise AstrBotError.invalid_input( - "Unknown SDK dispatch token for platform send" - ) - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is None: - raise AstrBotError.cancelled("The SDK request overlay has been closed") - if request_context.cancelled: - raise AstrBotError.cancelled("The SDK request has been cancelled") - - def mark_platform_send(self, dispatch_token: str) -> str: - request_context = self._request_contexts.get(dispatch_token) - if request_context is None: - raise AstrBotError.invalid_input( - "Unknown SDK dispatch token for platform send" - ) - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is None: - raise AstrBotError.cancelled("The SDK request overlay has been closed") - if request_context.cancelled: - raise AstrBotError.cancelled("The SDK request has been cancelled") - if request_context.dispatch_state is not None: - request_context.dispatch_state.sent_message = True - overlay.should_call_llm = False - if request_context.has_event: - request_context.event._has_send_oper = True - return f"sdk_{dispatch_token}" - - @staticmethod - def _legacy_has_replied(event: AstrMessageEvent) -> bool: - return getattr(event, "_has_send_oper", False) - - def _match_handlers(self, event: AstrMessageEvent) -> list[TriggerMatch]: - matches: list[TriggerMatch] = [] - for record in self._records.values(): - if record.state in {SDK_STATE_DISABLED, SDK_STATE_FAILED}: - continue - for handler in record.handlers: - match = TriggerConverter.match_handler( - plugin_id=record.plugin_id, - descriptor=handler.descriptor, - event=event, - load_order=record.load_order, - declaration_order=handler.declaration_order, - ) - if match is not None: - matches.append(match) - dynamic_base_order = len(record.handlers) - for route in getattr(record, "dynamic_command_routes", []): - match = self._match_dynamic_command_route( - record=record, - route=route, - event=event, - declaration_order=dynamic_base_order + route.declaration_order, - ) - if match is not None: - matches.append(match) - matches.sort(key=TriggerConverter.sort_key) - return matches - - def _match_dynamic_command_route( - self, - *, - record: SdkPluginRecord, - route: SdkDynamicCommandRoute, - event: AstrMessageEvent, - declaration_order: int, - ) -> TriggerMatch | None: - handler_ref = self._find_handler_ref(record, route.handler_full_name) - if handler_ref is None: - return None - descriptor = handler_ref.descriptor.model_copy(deep=True) - descriptor.priority = route.priority - if route.use_regex: - descriptor.trigger = MessageTrigger(regex=route.command_name) - else: - descriptor.trigger = CommandTrigger( - command=route.command_name, - description=route.desc or None, - ) - return TriggerConverter.match_handler( - plugin_id=record.plugin_id, - descriptor=descriptor, - event=event, - load_order=record.load_order, - declaration_order=declaration_order, - ) - - @staticmethod - def _find_handler_ref( - record: SdkPluginRecord, - handler_full_name: str, - ) -> SdkHandlerRef | None: - for handler in record.handlers: - if handler.descriptor.id == handler_full_name: - return handler - return None - - async def dispatch_system_event( - self, - event_type: str, - payload: dict[str, Any] | None = None, - ) -> None: - event_payload = { - "type": event_type, - "event_type": event_type, - "text": str((payload or {}).get("message_outline", "")), - "session_id": str((payload or {}).get("session_id", "")), - "platform": str((payload or {}).get("platform", "")), - "platform_id": str((payload or {}).get("platform_id", "")), - "message_type": str((payload or {}).get("message_type", "")), - "sender_name": str((payload or {}).get("sender_name", "")), - "self_id": str((payload or {}).get("self_id", "")), - "raw": {"event_type": event_type, **(payload or {})}, - } - for key, value in (payload or {}).items(): - event_payload[key] = value - matches = self._match_event_handlers(event_type) - for record, descriptor in matches: - if record.session is None: - continue - try: - await record.session.invoke_handler( - descriptor.id, - event_payload, - request_id=f"sdk_event_{record.plugin_id}_{uuid.uuid4().hex}", - args={}, - ) - except Exception as exc: - logger.warning( - "SDK event handler failed: plugin=%s handler=%s error=%s", - record.plugin_id, - descriptor.id, - exc, - ) - - async def dispatch_message_event( - self, - event_type: str, - event: AstrMessageEvent, - payload: dict[str, Any] | None = None, - *, - provider_request: CoreProviderRequest | None = None, - llm_response: CoreLLMResponse | None = None, - event_result: MessageEventResult | None = None, - ) -> None: - dispatch_token = self._get_dispatch_token(event) - if not dispatch_token: - return - overlay = self.get_request_overlay_by_token(dispatch_token) - if overlay is None: - return - matches = self._match_event_handlers( - event_type, - allowed_plugins=overlay.handler_whitelist, - ) - for record, descriptor in matches: - if record.session is None: - continue - request_id = f"sdk_event_{record.plugin_id}_{uuid.uuid4().hex}" - request_context = self._request_contexts.get(dispatch_token) - if request_context is None: - request_context = _RequestContext( - plugin_id=record.plugin_id, - request_id=request_id, - dispatch_token=dispatch_token, - dispatch_state=_DispatchState(event=event), - ) - self._request_contexts[dispatch_token] = request_context - request_context.plugin_id = record.plugin_id - request_context.request_id = request_id - request_context.dispatch_state.event = event - request_context.cancelled = False - self._track_request_scope( - dispatch_token=dispatch_token, - request_id=request_id, - plugin_id=record.plugin_id, - ) - event_payload = EventConverter.core_to_sdk( - event, - dispatch_token=dispatch_token, - plugin_id=record.plugin_id, - request_id=request_id, - ) - event_payload["type"] = event_type - event_payload["event_type"] = event_type - event_payload["raw"] = { - **( - event_payload["raw"] - if isinstance(event_payload.get("raw"), dict) - else {} - ), - "event_type": event_type, - **(payload or {}), - } - for key, value in (payload or {}).items(): - event_payload[key] = value - self._apply_request_scoped_event_payload(event_payload, overlay) - if provider_request is not None: - request_payload = self._core_provider_request_to_sdk_payload( - provider_request - ) - event_payload["provider_request"] = request_payload - if isinstance(event_payload["raw"], dict): - event_payload["raw"]["provider_request"] = request_payload - if llm_response is not None: - response_payload = self._core_llm_response_to_sdk_payload(llm_response) - event_payload["llm_response"] = response_payload - if isinstance(event_payload["raw"], dict): - event_payload["raw"]["llm_response"] = response_payload - if event_result is not None: - result_payload = self._legacy_result_to_sdk_payload(event_result) - if result_payload is not None: - event_payload["event_result"] = result_payload - if isinstance(event_payload["raw"], dict): - event_payload["raw"]["event_result"] = result_payload - try: - output = await record.session.invoke_handler( - descriptor.id, - event_payload, - request_id=request_id, - args={}, - ) - if isinstance(output, dict): - if "sdk_local_extras" in output: - self._persist_sdk_local_extras_from_handler( - overlay, - output.get("sdk_local_extras"), - plugin_id=record.plugin_id, - handler_id=descriptor.id, - ) - request_payload = output.get("provider_request") - if provider_request is not None and isinstance( - request_payload, dict - ): - self._apply_sdk_provider_request_payload( - provider_request, - request_payload, - ) - result_payload = output.get("event_result") - if event_result is not None and isinstance(result_payload, dict): - if not self.set_result_for_request(request_id, result_payload): - self._apply_sdk_result_payload(event_result, result_payload) - except Exception as exc: - logger.warning( - "SDK event handler failed: plugin=%s handler=%s error=%s", - record.plugin_id, - descriptor.id, - exc, - ) - - def _match_event_handlers( - self, - event_type: str, - *, - allowed_plugins: set[str] | None = None, - ) -> list[tuple[SdkPluginRecord, HandlerDescriptor]]: - matches: list[tuple[int, int, int, SdkPluginRecord, HandlerDescriptor]] = [] - for record in self._records.values(): - if record.state in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - }: - continue - if allowed_plugins is not None and record.plugin_id not in allowed_plugins: - continue - for handler in record.handlers: - trigger = handler.descriptor.trigger - if not isinstance(trigger, EventTrigger): - continue - if trigger.event_type != event_type: - continue - matches.append( - ( - -handler.descriptor.priority, - record.load_order, - handler.declaration_order, - record, - handler.descriptor, - ) - ) - matches.sort(key=lambda item: (item[0], item[1], item[2])) - return [(record, descriptor) for _, _, _, record, descriptor in matches] - - @staticmethod - def _descriptor_event_types(descriptor: HandlerDescriptor) -> list[str]: - trigger = descriptor.trigger - if isinstance(trigger, EventTrigger): - return [trigger.event_type] - return [] - - @staticmethod - def _descriptor_group_path(descriptor: HandlerDescriptor) -> list[str]: - route = getattr(descriptor, "command_route", None) - if route is None: - return [] - return list(route.group_path) - - @staticmethod - def _descriptor_description(descriptor: HandlerDescriptor) -> str | None: - description = str(descriptor.description or "").strip() - if description: - return description - trigger = descriptor.trigger - if isinstance(trigger, CommandTrigger): - command_description = str(trigger.description or "").strip() - if command_description: - return command_description - return None - - def _descriptor_metadata( - self, - *, - plugin_id: str, - descriptor: HandlerDescriptor, - ) -> dict[str, Any]: - return { - "plugin_name": plugin_id, - "handler_full_name": descriptor.id, - "trigger_type": getattr(descriptor.trigger, "type", ""), - "description": self._descriptor_description(descriptor), - "event_types": self._descriptor_event_types(descriptor), - "enabled": True, - "group_path": self._descriptor_group_path(descriptor), - "priority": descriptor.priority, - "kind": descriptor.kind, - "require_admin": descriptor.permissions.require_admin, - } - - def get_handlers_by_event_type(self, event_type: str) -> list[dict[str, Any]]: - entries: list[dict[str, Any]] = [] - for record in sorted(self._records.values(), key=lambda item: item.load_order): - if record.state in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - }: - continue - for handler in record.handlers: - trigger = handler.descriptor.trigger - if ( - isinstance(trigger, EventTrigger) - and trigger.event_type == event_type - ): - entries.append( - self._descriptor_metadata( - plugin_id=record.plugin_id, - descriptor=handler.descriptor, - ) - ) - if event_type == "message": - for route in getattr(record, "dynamic_command_routes", []): - descriptor = self._build_dynamic_route_descriptor(record, route) - if descriptor is None: - continue - entries.append( - self._descriptor_metadata( - plugin_id=record.plugin_id, - descriptor=descriptor, - ) - ) - return entries - - def list_native_command_candidates( - self, - platform_name: str, - ) -> list[dict[str, Any]]: - """Expose SDK commands that can be surfaced in native platform menus. - - Native platform command menus are top-level and single-token, so grouped - SDK commands are exported as their root command (for example ``gf`` for - ``gf chat`` / ``gf affection``). - """ - normalized_platform = str(platform_name).strip().lower() - if not normalized_platform: - return [] - - entries: list[dict[str, Any]] = [] - seen_names: set[str] = set() - - for record in sorted(self._records.values(), key=lambda item: item.load_order): - if record.state in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - }: - continue - if not self._record_supports_platform(record, normalized_platform): - continue - - for handler in record.handlers: - for entry in self._descriptor_native_command_candidates( - handler.descriptor, - platform_name=normalized_platform, - ): - name = str(entry.get("name", "")).strip().lower() - if not name or name in seen_names: - continue - seen_names.add(name) - entries.append(entry) - - for route in getattr(record, "dynamic_command_routes", []): - descriptor = self._build_dynamic_route_descriptor(record, route) - if descriptor is None: - continue - for entry in self._descriptor_native_command_candidates( - descriptor, - platform_name=normalized_platform, - ): - name = str(entry.get("name", "")).strip().lower() - if not name or name in seen_names: - continue - seen_names.add(name) - entries.append(entry) - - return entries - - def get_handler_by_full_name(self, full_name: str) -> dict[str, Any] | None: - for record in self._records.values(): - for handler in record.handlers: - if handler.descriptor.id == full_name: - return self._descriptor_metadata( - plugin_id=record.plugin_id, - descriptor=handler.descriptor, - ) - return None - - def list_dashboard_commands(self) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for record in sorted(self._records.values(), key=lambda item: item.load_order): - items.extend(self._build_dashboard_command_items(record)) - items.sort(key=lambda item: str(item.get("effective_command", "")).lower()) - return items - - def list_dashboard_tools(self) -> list[dict[str, Any]]: - tools: list[dict[str, Any]] = [] - for record in sorted(self._records.values(), key=lambda item: item.load_order): - display_name = str( - record.plugin.manifest_data.get("display_name") or record.plugin_id - ) - plugin_enabled = record.state not in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - } - for spec in sorted(record.llm_tools.values(), key=lambda item: item.name): - tools.append( - { - "tool_key": (f"sdk:{record.plugin_id}:{spec.name}"), - "name": spec.name, - "description": spec.description, - "parameters": dict(spec.parameters_schema), - "active": bool(spec.active) and plugin_enabled, - "origin": "sdk_plugin", - "origin_name": display_name, - "runtime_kind": "sdk", - "plugin_id": record.plugin_id, - } - ) - return tools - - def _build_dashboard_command_items( - self, - record: SdkPluginRecord, - ) -> list[dict[str, Any]]: - flat_commands: list[dict[str, Any]] = [] - for handler in record.handlers: - entry = self._build_dashboard_command_entry( - record=record, - descriptor=handler.descriptor, - ) - if entry is not None: - flat_commands.append(entry) - for route in getattr(record, "dynamic_command_routes", []): - descriptor = self._build_dynamic_route_descriptor(record, route) - if descriptor is None: - continue - entry = self._build_dashboard_command_entry( - record=record, - descriptor=descriptor, - route=route, - ) - if entry is not None: - flat_commands.append(entry) - - groups: dict[str, dict[str, Any]] = {} - root_items: list[dict[str, Any]] = [] - for entry in flat_commands: - parent_signature = str(entry.get("parent_signature", "")).strip() - if not parent_signature: - root_items.append(entry) - continue - group_key = self._dashboard_group_key(record.plugin_id, parent_signature) - group = groups.get(group_key) - if group is None: - group = { - "command_key": group_key, - "handler_full_name": group_key, - "handler_name": parent_signature.split()[-1] or record.plugin_id, - "plugin": record.plugin_id, - "plugin_display_name": str( - record.plugin.manifest_data.get("display_name") - or record.plugin_id - ), - "module_path": str(record.plugin.plugin_dir), - "description": entry.pop("_group_help", "") or "", - "type": "group", - "parent_signature": "", - "parent_group_handler": "", - "original_command": parent_signature, - "current_fragment": parent_signature.split()[-1] - if parent_signature - else "", - "effective_command": parent_signature, - "aliases": [], - "permission": "everyone", - "enabled": bool(entry.get("enabled", False)), - "is_group": True, - "has_conflict": False, - "reserved": False, - "runtime_kind": "sdk", - "supports_toggle": False, - "supports_rename": False, - "supports_permission": False, - "sub_commands": [], - } - groups[group_key] = group - root_items.append(group) - elif not group.get("description") and entry.get("_group_help"): - group["description"] = entry["_group_help"] - - if entry.get("permission") == "admin": - group["permission"] = "admin" - group["enabled"] = bool(group["enabled"]) or bool( - entry.get("enabled", False) - ) - entry["parent_group_handler"] = group["handler_full_name"] - entry.pop("_group_help", None) - group["sub_commands"].append(entry) - - for group in groups.values(): - group["sub_commands"].sort( - key=lambda item: str(item.get("effective_command", "")).lower() - ) - for item in root_items: - item.pop("_group_help", None) - return root_items - - def _build_dashboard_command_entry( - self, - *, - record: SdkPluginRecord, - descriptor: HandlerDescriptor, - route: SdkDynamicCommandRoute | None = None, - ) -> dict[str, Any] | None: - trigger = descriptor.trigger - if not isinstance(trigger, CommandTrigger): - return None - - route_meta = descriptor.command_route - effective_command = ( - str(route_meta.display_command).strip() - if route_meta is not None and str(route_meta.display_command).strip() - else str(trigger.command).strip() - ) - parent_signature = "" - group_help = "" - if route_meta is not None and route_meta.group_path: - parent_signature = " ".join( - str(item).strip() for item in route_meta.group_path if str(item).strip() - ).strip() - group_help = str(route_meta.group_help or "").strip() - - current_fragment = effective_command - if parent_signature and effective_command.startswith(f"{parent_signature} "): - current_fragment = effective_command[len(parent_signature) + 1 :].strip() - - enabled = record.state not in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - } - return { - "command_key": self._dashboard_command_key( - plugin_id=record.plugin_id, - handler_full_name=descriptor.id, - route=route, - ), - "handler_full_name": descriptor.id, - "handler_name": descriptor.id.rsplit(".", 1)[-1], - "plugin": record.plugin_id, - "plugin_display_name": str( - record.plugin.manifest_data.get("display_name") or record.plugin_id - ), - "module_path": descriptor.id.rsplit(".", 1)[0], - "description": self._descriptor_description(descriptor) or "", - "type": "sub_command" if parent_signature else "command", - "parent_signature": parent_signature, - "parent_group_handler": "", - "original_command": effective_command, - "current_fragment": current_fragment, - "effective_command": effective_command, - "aliases": list(trigger.aliases), - "permission": ( - "admin" if descriptor.permissions.require_admin else "everyone" - ), - "enabled": enabled, - "is_group": False, - "has_conflict": False, - "reserved": False, - "runtime_kind": "sdk", - "supports_toggle": False, - "supports_rename": False, - "supports_permission": False, - "sub_commands": [], - "_group_help": group_help, - } - - @staticmethod - def _dashboard_command_key( - *, - plugin_id: str, - handler_full_name: str, - route: SdkDynamicCommandRoute | None, - ) -> str: - if route is None: - return f"sdk:command:{plugin_id}:{handler_full_name}" - route_kind = "regex" if route.use_regex else "command" - return f"sdk:route:{plugin_id}:{handler_full_name}:{route_kind}:{route.command_name}" - - @staticmethod - def _dashboard_group_key(plugin_id: str, parent_signature: str) -> str: - return f"sdk:group:{plugin_id}:{parent_signature}" - - def _build_dynamic_route_descriptor( - self, - record: SdkPluginRecord, - route: SdkDynamicCommandRoute, - ) -> HandlerDescriptor | None: - handler_ref = self._find_handler_ref(record, route.handler_full_name) - if handler_ref is None: - return None - descriptor = handler_ref.descriptor.model_copy(deep=True) - descriptor.priority = route.priority - if route.use_regex: - descriptor.trigger = MessageTrigger(regex=route.command_name) - else: - descriptor.trigger = CommandTrigger( - command=route.command_name, - description=route.desc or None, - ) - return descriptor - - @staticmethod - def _record_supports_platform( - record: SdkPluginRecord, - platform_name: str, - ) -> bool: - support_platforms = record.plugin.manifest_data.get("support_platforms") - if not isinstance(support_platforms, list): - return True - normalized = { - str(item).strip().lower() for item in support_platforms if str(item).strip() - } - if not normalized: - return True - return platform_name in normalized - - @staticmethod - def _local_mcp_tool_name(server_name: str, tool_name: str) -> str: - return f"mcp.{server_name}.{tool_name}" - - @staticmethod - def _local_mcp_tool_ref(server_name: str, tool_name: str) -> str: - return json.dumps( - {"server_name": server_name, "tool_name": tool_name}, - ensure_ascii=True, - separators=(",", ":"), - ) - - @staticmethod - def _plugin_data_dir(plugin_id: str) -> Path: - return Path(get_astrbot_plugin_data_path()) / plugin_id - - @classmethod - def _plugin_mcp_lease_dir(cls, plugin_id: str) -> Path: - return cls._plugin_data_dir(plugin_id) / ".mcp_leases" - - def acknowledges_global_mcp_risk(self, plugin_id: str) -> bool: - record = self._records.get(plugin_id) - return bool(record and record.acknowledge_global_mcp_risk) - - def _load_local_mcp_configs(self, plugin: PluginSpec) -> dict[str, dict[str, Any]]: - config_path = plugin.plugin_dir / "mcp.json" - if not config_path.exists(): - return {} - try: - payload = json.loads(config_path.read_text(encoding="utf-8")) - except Exception as exc: - logger.warning( - "Failed to read SDK plugin mcp.json %s: %s", config_path, exc - ) - return {} - if not isinstance(payload, dict): - logger.warning("Ignoring invalid SDK plugin mcp.json root: %s", config_path) - return {} - servers = payload.get("mcpServers") - if not isinstance(servers, dict): - logger.warning( - "Ignoring SDK plugin mcp.json without mcpServers: %s", config_path - ) - return {} - return { - str(name): dict(config) - for name, config in servers.items() - if str(name).strip() and isinstance(config, dict) - } - - @classmethod - def _build_local_mcp_tool_specs( - cls, - server_name: str, - client: MCPClient, - ) -> list[LLMToolSpec]: - specs: list[LLMToolSpec] = [] - for tool in client.tools: - raw_tool_name = str(getattr(tool, "name", "")).strip() - if not raw_tool_name: - continue - parameters_schema = getattr(tool, "inputSchema", None) - if not isinstance(parameters_schema, dict): - parameters_schema = {"type": "object", "properties": {}} - specs.append( - LLMToolSpec.create( - name=cls._local_mcp_tool_name(server_name, raw_tool_name), - description=str(getattr(tool, "description", "") or ""), - parameters_schema=dict(parameters_schema), - handler_ref=cls._local_mcp_tool_ref(server_name, raw_tool_name), - handler_capability="internal.mcp.local.execute", - active=True, - ) - ) - return specs - - @staticmethod - def _mcp_call_result_to_text(result: Any) -> str | None: - content_items = getattr(result, "content", None) - if not isinstance(content_items, list): - return None - chunks: list[str] = [] - for item in content_items: - text = getattr(item, "text", None) - if isinstance(text, str): - chunks.append(text) - continue - model_dump = getattr(item, "model_dump", None) - if callable(model_dump): - chunks.append(json.dumps(model_dump(), ensure_ascii=False)) - continue - if item is not None: - chunks.append(str(item)) - return "\n".join(part for part in chunks if part).strip() or None - - async def _cleanup_mcp_client(self, client: MCPClient | None) -> None: - if client is None: - return - with contextlib.suppress(Exception): - await client.cleanup() - - def _write_local_mcp_lease( - self, - *, - plugin_id: str, - server_name: str, - pid: int, - ) -> Path: - lease_dir = self._plugin_mcp_lease_dir(plugin_id) - lease_dir.mkdir(parents=True, exist_ok=True) - lease_path = lease_dir / f"{server_name}.json" - lease_path.write_text( - json.dumps( - { - "pid": int(pid), - "plugin_id": plugin_id, - "server_name": server_name, - }, - ensure_ascii=True, - indent=2, - ), - encoding="utf-8", - ) - return lease_path - - @staticmethod - def _remove_local_mcp_lease(runtime: _LocalMCPServerRuntime) -> None: - lease_path = runtime.lease_path - runtime.lease_path = None - if lease_path is None: - return - with contextlib.suppress(OSError): - lease_path.unlink() - - def _terminate_stale_mcp_pid(self, pid: int) -> None: - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - return - except PermissionError: - logger.warning("Permission denied while terminating stale MCP pid %s", pid) - return - except OSError as exc: - logger.warning("Failed to terminate stale MCP pid %s: %s", pid, exc) - - def _sweep_stale_mcp_leases(self) -> None: - plugin_data_root = Path(get_astrbot_plugin_data_path()) - if not plugin_data_root.exists(): - return - for lease_path in plugin_data_root.glob("*/.mcp_leases/*.json"): - try: - payload = json.loads(lease_path.read_text(encoding="utf-8")) - except Exception: - payload = {} - pid = payload.get("pid") - if pid is not None: - with contextlib.suppress(TypeError, ValueError): - self._terminate_stale_mcp_pid(int(pid)) - with contextlib.suppress(OSError): - lease_path.unlink() - - async def _connect_local_mcp_server( - self, - *, - plugin_id: str, - runtime: _LocalMCPServerRuntime, - timeout: float, - ) -> None: - runtime.ready_event.clear() - runtime.running = False - runtime.last_error = None - runtime.errlogs = [] - runtime.tools = [] - runtime.tool_specs = [] - self._remove_local_mcp_lease(runtime) - await self._cleanup_mcp_client(runtime.client) - runtime.client = None - - client = MCPClient() - client.name = runtime.name - try: - await asyncio.wait_for( - client.connect_to_server(dict(runtime.config), runtime.name), - timeout=timeout, - ) - await asyncio.wait_for(client.list_tools_and_save(), timeout=timeout) - except asyncio.CancelledError: - await self._cleanup_mcp_client(client) - raise - except TimeoutError: - runtime.last_error = ( - f"Local MCP server '{runtime.name}' did not become ready within " - f"{timeout:g} seconds" - ) - runtime.errlogs = [runtime.last_error] - await self._cleanup_mcp_client(client) - except Exception as exc: - runtime.last_error = str(exc) - runtime.errlogs = [runtime.last_error] - await self._cleanup_mcp_client(client) - else: - runtime.client = client - runtime.running = True - runtime.tools = [ - str(tool.name) for tool in client.tools if getattr(tool, "name", None) - ] - runtime.tool_specs = self._build_local_mcp_tool_specs(runtime.name, client) - runtime.errlogs = list(client.server_errlogs) - if client.process_pid is not None: - runtime.lease_path = self._write_local_mcp_lease( - plugin_id=plugin_id, - server_name=runtime.name, - pid=client.process_pid, - ) - finally: - runtime.ready_event.set() - runtime.connect_task = None - - async def _initialize_local_mcp_servers(self, record: SdkPluginRecord) -> None: - tasks: list[asyncio.Task[None]] = [] - for runtime in record.local_mcp_servers.values(): - if not runtime.active: - runtime.ready_event.set() - continue - task = asyncio.create_task( - self._connect_local_mcp_server( - plugin_id=record.plugin_id, - runtime=runtime, - timeout=30.0, - ) - ) - runtime.connect_task = task - tasks.append(task) - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - async def _shutdown_local_mcp_runtime( - self, - runtime: _LocalMCPServerRuntime, - ) -> None: - connect_task = runtime.connect_task - runtime.connect_task = None - if connect_task is not None and not connect_task.done(): - connect_task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await connect_task - self._remove_local_mcp_lease(runtime) - await self._cleanup_mcp_client(runtime.client) - runtime.client = None - runtime.running = False - runtime.tools = [] - runtime.tool_specs = [] - runtime.ready_event.clear() - - async def _shutdown_local_mcp_servers(self, record: SdkPluginRecord) -> None: - for runtime in record.local_mcp_servers.values(): - await self._shutdown_local_mcp_runtime(runtime) - - async def enable_local_mcp_server( - self, - plugin_id: str, - name: str, - *, - timeout: float = 30.0, - ) -> dict[str, Any]: - runtime = self._local_mcp_record(plugin_id, name) - if runtime is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - if runtime.active and runtime.running and runtime.connect_task is None: - return self._serialize_local_mcp_server(runtime) - if runtime.connect_task is not None and not runtime.connect_task.done(): - runtime.active = True - await runtime.connect_task - return self._serialize_local_mcp_server(runtime) - runtime.active = True - task = asyncio.create_task( - self._connect_local_mcp_server( - plugin_id=plugin_id, - runtime=runtime, - timeout=timeout, - ) - ) - runtime.connect_task = task - await task - return self._serialize_local_mcp_server(runtime) - - async def disable_local_mcp_server( - self, - plugin_id: str, - name: str, - ) -> dict[str, Any]: - runtime = self._local_mcp_record(plugin_id, name) - if runtime is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - if not runtime.active and not runtime.running and runtime.connect_task is None: - return self._serialize_local_mcp_server(runtime) - runtime.active = False - await self._shutdown_local_mcp_runtime(runtime) - return self._serialize_local_mcp_server(runtime) - - async def wait_for_local_mcp_server( - self, - plugin_id: str, - name: str, - *, - timeout: float, - ) -> dict[str, Any]: - runtime = self._local_mcp_record(plugin_id, name) - if runtime is None: - raise AstrBotError.invalid_input(f"Unknown local MCP server: {name}") - await asyncio.wait_for(runtime.ready_event.wait(), timeout=timeout) - if not runtime.running: - raise TimeoutError( - f"Local MCP server '{name}' did not become ready in time" - ) - return self._serialize_local_mcp_server(runtime) - - async def open_temporary_mcp_session( - self, - plugin_id: str, - *, - name: str, - config: dict[str, Any], - timeout: float, - ) -> tuple[str, list[str]]: - client = MCPClient() - client.name = name - try: - await asyncio.wait_for( - client.connect_to_server(dict(config), name), - timeout=timeout, - ) - await asyncio.wait_for(client.list_tools_and_save(), timeout=timeout) - except Exception: - await self._cleanup_mcp_client(client) - raise - session_id = f"{plugin_id}:{uuid.uuid4().hex}" - tools = [str(tool.name) for tool in client.tools if getattr(tool, "name", None)] - self._temporary_mcp_sessions[session_id] = _TemporaryMCPSessionRuntime( - plugin_id=plugin_id, - name=name, - client=client, - tools=tools, - ) - return session_id, tools - - async def close_temporary_mcp_session( - self, - plugin_id: str, - session_id: str, - ) -> None: - runtime = self._temporary_mcp_sessions.get(session_id) - if runtime is None or runtime.plugin_id != plugin_id: - return - self._temporary_mcp_sessions.pop(session_id, None) - await self._cleanup_mcp_client(runtime.client) - - async def _close_temporary_mcp_sessions(self, plugin_id: str) -> None: - session_ids = [ - session_id - for session_id, runtime in self._temporary_mcp_sessions.items() - if runtime.plugin_id == plugin_id - ] - for session_id in session_ids: - await self.close_temporary_mcp_session(plugin_id, session_id) - - def get_temporary_mcp_session_tools( - self, - plugin_id: str, - session_id: str, - ) -> list[str]: - runtime = self._temporary_mcp_sessions.get(session_id) - if runtime is None or runtime.plugin_id != plugin_id: - raise AstrBotError.invalid_input("Unknown MCP session") - return list(runtime.tools) - - async def call_temporary_mcp_tool( - self, - plugin_id: str, - *, - session_id: str, - tool_name: str, - arguments: dict[str, Any], - ) -> dict[str, Any]: - runtime = self._temporary_mcp_sessions.get(session_id) - if runtime is None or runtime.plugin_id != plugin_id: - raise AstrBotError.invalid_input("Unknown MCP session") - result = await runtime.client.call_tool_with_reconnect( - tool_name=tool_name, - arguments=arguments, - read_timeout_seconds=timedelta(seconds=60), - ) - text = self._mcp_call_result_to_text(result) - return {"content": text, "is_error": bool(getattr(result, "isError", False))} - - async def execute_local_mcp_tool( - self, - plugin_id: str, - *, - server_name: str, - tool_name: str, - tool_args: dict[str, Any], - timeout_seconds: int = 60, - ) -> dict[str, Any]: - runtime = self._local_mcp_record(plugin_id, server_name) - if ( - runtime is None - or not runtime.active - or not runtime.running - or runtime.client is None - ): - return { - "content": f"Local MCP server unavailable: {server_name}", - "success": False, - } - if tool_name not in runtime.tools: - return { - "content": f"Local MCP tool not found: {server_name}.{tool_name}", - "success": False, - } - try: - result = await runtime.client.call_tool_with_reconnect( - tool_name=tool_name, - arguments=tool_args, - read_timeout_seconds=timedelta(seconds=timeout_seconds), - ) - except Exception as exc: - return {"content": f"Tool execution failed: {exc}", "success": False} - text = self._mcp_call_result_to_text(result) - return { - "content": text, - "success": not bool(getattr(result, "isError", False)), - } - - @classmethod - def _descriptor_native_command_candidates( - cls, - descriptor: HandlerDescriptor, - *, - platform_name: str, - ) -> list[dict[str, Any]]: - trigger = descriptor.trigger - if not isinstance(trigger, CommandTrigger): - return [] - if not cls._descriptor_supports_platform(descriptor, platform_name): - return [] - - names = [trigger.command, *trigger.aliases] - route = descriptor.command_route - root_candidates: list[str] = [] - - if route is not None and route.group_path: - root_candidates.append(str(route.group_path[0]).strip()) - - for name in names: - normalized = str(name).strip() - if " " not in normalized: - continue - root_candidates.append(normalized.split()[0].strip()) - - if root_candidates: - description = ( - str(route.group_help).strip() - if route is not None and route.group_help - else str(trigger.description or "").strip() - ) - root_name = next((item for item in root_candidates if item), "") - if not description and root_name: - description = f"Command group: {root_name}" - unique_roots = [ - item - for item in dict.fromkeys(root_candidates) - if isinstance(item, str) and item.strip() - ] - return [ - { - "name": item.strip(), - "description": description, - "is_group": True, - } - for item in unique_roots - ] - - description = str(trigger.description or "").strip() - if not description and trigger.command.strip(): - description = f"Command: {trigger.command.strip()}" - unique_names = [ - item for item in dict.fromkeys(str(name).strip() for name in names) if item - ] - return [ - { - "name": item, - "description": description, - "is_group": False, - } - for item in unique_names - ] - - @classmethod - def _descriptor_supports_platform( - cls, - descriptor: HandlerDescriptor, - platform_name: str, - ) -> bool: - trigger_platforms = getattr(descriptor.trigger, "platforms", []) - if isinstance(trigger_platforms, list): - normalized = { - str(item).strip().lower() - for item in trigger_platforms - if str(item).strip() - } - if normalized and platform_name not in normalized: - return False - for filter_spec in descriptor.filters: - if not cls._filter_supports_platform(filter_spec, platform_name): - return False - return True - - @classmethod - def _filter_supports_platform(cls, filter_spec, platform_name: str) -> bool: - if isinstance(filter_spec, PlatformFilterSpec): - normalized = { - str(item).strip().lower() - for item in filter_spec.platforms - if str(item).strip() - } - return not normalized or platform_name in normalized - if isinstance(filter_spec, CompositeFilterSpec): - platform_children = [ - child - for child in filter_spec.children - if isinstance(child, PlatformFilterSpec | CompositeFilterSpec) - ] - if not platform_children: - return True - results = [ - cls._filter_supports_platform(child, platform_name) - for child in platform_children - ] - if filter_spec.kind == "and": - return all(results) - return any(results) - return True - - async def _load_or_reload_plugin( - self, - plugin: PluginSpec, - *, - load_order: int, - reset_restart_budget: bool, - ) -> None: - current = self._records.get(plugin.name) - if current is not None: - current.state = SDK_STATE_RELOADING - await self._cancel_plugin_requests(plugin.name) - await self._teardown_plugin(plugin.name) - - disabled = bool( - self._state_overrides.get(plugin.name, {}).get("disabled", False) - ) - config_schema = load_plugin_config_schema(plugin) - local_mcp_configs = self._load_local_mcp_configs(plugin) - record = SdkPluginRecord( - plugin=plugin, - load_order=load_order, - state=SDK_STATE_DISABLED if disabled else SDK_STATE_ENABLED, - unsupported_features=[], - config_schema=config_schema, - config=load_plugin_config(plugin, schema=config_schema), - handlers=[], - llm_tools={}, - active_llm_tools=set(), - agents={}, - restart_attempted=False - if reset_restart_budget - else (current.restart_attempted if current is not None else False), - issues=[dict(item) for item in self._discovery_issues.get(plugin.name, [])], - local_mcp_servers={ - name: _LocalMCPServerRuntime( - name=name, - config=dict(config), - active=bool(config.get("active", True)), - ) - for name, config in local_mcp_configs.items() - }, - ) - self._records[plugin.name] = record - self._publish_plugin_skills(plugin.name) - if disabled: - self._persist_state_overrides() - return - - try: - - def _schedule_closed(plugin_id: str = plugin.name) -> None: - asyncio.create_task(self._handle_worker_closed(plugin_id)) - - session = WorkerSession( - plugin=plugin, - repo_root=Path(__file__).resolve().parents[3], - env_manager=self.env_manager, - capability_router=self.capability_bridge, - on_closed=_schedule_closed, - ) - await session.start() - session.start_close_watch() - record.session = session - remote_metadata = ( - dict(session.peer.remote_metadata) - if session.peer is not None - and isinstance(session.peer.remote_metadata, dict) - else {} - ) - record.acknowledge_global_mcp_risk = bool( - remote_metadata.get("acknowledge_global_mcp_risk", False) - ) - unsupported_features: set[str] = set() - for index, descriptor in enumerate(session.handlers): - if ( - isinstance(descriptor.trigger, EventTrigger) - and descriptor.trigger.event_type not in SUPPORTED_SYSTEM_EVENTS - ): - unsupported_features.add("event_trigger") - record.handlers.append( - SdkHandlerRef( - descriptor=descriptor, - declaration_order=index, - ) - ) - for item in session.llm_tools: - if not isinstance(item, dict): - continue - plugin_name = str(item.get("plugin_id") or plugin.name) - if plugin_name != plugin.name: - continue - normalized = dict(item) - normalized.pop("plugin_id", None) - spec = LLMToolSpec.from_payload(normalized) - record.llm_tools[spec.name] = spec - if spec.active: - record.active_llm_tools.add(spec.name) - for item in session.agents: - if not isinstance(item, dict): - continue - plugin_name = str(item.get("plugin_id") or plugin.name) - if plugin_name != plugin.name: - continue - normalized = dict(item) - normalized.pop("plugin_id", None) - spec = AgentSpec.from_payload(normalized) - record.agents[spec.name] = spec - await self._register_schedule_handlers(record) - await self._initialize_local_mcp_servers(record) - record.issues.extend(issue.to_payload() for issue in session.issues) - record.unsupported_features = sorted(unsupported_features) - record.state = ( - SDK_STATE_UNSUPPORTED_PARTIAL - if record.unsupported_features - else SDK_STATE_ENABLED - ) - record.failure_reason = "" - except Exception as exc: - record.session = None - record.state = SDK_STATE_FAILED - record.failure_reason = str(exc) - record.issues.append( - PluginDiscoveryIssue( - severity="error", - phase="load", - plugin_id=plugin.name, - message="插件 worker 启动失败", - details=str(exc), - ).to_payload() - ) - logger.warning("Failed to start SDK plugin %s: %s", plugin.name, exc) - finally: - self._persist_state_overrides() - - async def _teardown_plugin(self, plugin_id: str) -> None: - record = self._records.get(plugin_id) - self._http_routes.pop(plugin_id, None) - self._session_waiters.pop(plugin_id, None) - await self._unregister_schedule_jobs(plugin_id) - await self._close_temporary_mcp_sessions(plugin_id) - skills_changed = False - if record is not None and record.skills: - record.skills.clear() - self._publish_plugin_skills(plugin_id) - skills_changed = True - if skills_changed: - try: - from astrbot.core.computer.computer_client import ( - sync_skills_to_active_sandboxes, - ) - - await sync_skills_to_active_sandboxes() - except Exception as exc: - logger.warning( - "Failed to sync skills after SDK plugin teardown %s: %s", - plugin_id, - exc, - ) - if record is None or record.session is None: - if record is not None: - await self._shutdown_local_mcp_servers(record) - return - try: - await self._shutdown_local_mcp_servers(record) - await record.session.stop() - finally: - record.session = None - - async def _register_schedule_handlers(self, record: SdkPluginRecord) -> None: - cron_manager = getattr(self.star_context, "cron_manager", None) - if cron_manager is None: - return - for handler in record.handlers: - trigger = handler.descriptor.trigger - if not isinstance(trigger, ScheduleTrigger): - continue - schedule_key = f"{record.plugin_id}:{handler.handler_id}" - job = await cron_manager.add_basic_job( - name=schedule_key, - cron_expression=trigger.cron, - interval_seconds=trigger.interval_seconds, - handler=self._build_schedule_runner( - plugin_id=record.plugin_id, - handler_id=handler.handler_id, - trigger=trigger, - ), - description=f"SDK schedule handler {handler.handler_id}", - enabled=True, - persistent=False, - ) - self._schedule_job_ids.setdefault(record.plugin_id, set()).add(job.job_id) - - async def _unregister_schedule_jobs(self, plugin_id: str) -> None: - cron_manager = getattr(self.star_context, "cron_manager", None) - if cron_manager is None: - return - for job_id in list(self._schedule_job_ids.pop(plugin_id, set())): - try: - await cron_manager.delete_job(job_id) - except Exception: - logger.debug("Failed to remove SDK schedule job {}", job_id) - - def _build_schedule_runner( - self, - *, - plugin_id: str, - handler_id: str, - trigger: ScheduleTrigger, - ): - async def _run() -> None: - await self._invoke_schedule_handler( - plugin_id=plugin_id, - handler_id=handler_id, - trigger=trigger, - ) - - return _run - - def _set_discovery_issues(self, issues: list[PluginDiscoveryIssue]) -> None: - grouped: dict[str, list[dict[str, Any]]] = {} - for issue in issues: - grouped.setdefault(issue.plugin_id, []).append(issue.to_payload()) - self._discovery_issues = grouped - - async def _invoke_schedule_handler( - self, - *, - plugin_id: str, - handler_id: str, - trigger: ScheduleTrigger, - ) -> None: - record = self._records.get(plugin_id) - if ( - record is None - or record.session is None - or record.state - in {SDK_STATE_DISABLED, SDK_STATE_FAILED, SDK_STATE_RELOADING} - ): - return - dispatch_token = uuid.uuid4().hex - request_id = f"sdk_schedule_{plugin_id}_{uuid.uuid4().hex}" - self._ensure_request_overlay(dispatch_token, should_call_llm=False) - self._request_contexts[dispatch_token] = _RequestContext( - plugin_id=plugin_id, - request_id=request_id, - dispatch_token=dispatch_token, - dispatch_state=None, - ) - self._track_request_scope( - dispatch_token=dispatch_token, - request_id=request_id, - plugin_id=plugin_id, - ) - payload = self._build_schedule_payload( - plugin_id=plugin_id, - handler_id=handler_id, - trigger=trigger, - ) - try: - await record.session.invoke_handler( - handler_id, - payload, - request_id=request_id, - args={}, - ) - except Exception as exc: - logger.warning( - "SDK schedule handler failed: plugin=%s handler=%s error=%s", - plugin_id, - handler_id, - exc, - ) - - @staticmethod - def _build_schedule_payload( - *, - plugin_id: str, - handler_id: str, - trigger: ScheduleTrigger, - ) -> dict[str, Any]: - scheduled_at = datetime.now(timezone.utc).isoformat() - return { - "type": "schedule", - "event_type": "schedule", - "text": "", - "session_id": "", - "platform": "", - "platform_id": "", - "message_type": "other", - "sender_name": "", - "self_id": "", - "raw": {"event_type": "schedule"}, - "schedule": { - "schedule_id": f"{plugin_id}:{handler_id}", - "plugin_id": plugin_id, - "handler_id": handler_id, - "trigger_kind": "cron" if trigger.cron is not None else "interval", - "cron": trigger.cron, - "interval_seconds": trigger.interval_seconds, - "scheduled_at": scheduled_at, - }, - } - - async def _cancel_plugin_requests(self, plugin_id: str) -> None: - requests = list(self._plugin_requests.get(plugin_id, {}).values()) - for inflight in requests: - request_context = self._request_contexts.get(inflight.dispatch_token) - if request_context is not None: - request_context.cancelled = True - self._close_request_overlay(inflight.dispatch_token) - record = self._records.get(plugin_id) - if ( - record is not None - and record.session is not None - and record.session.peer is not None - and not inflight.task.done() - ): - try: - await record.session.cancel(inflight.request_id) - except Exception: - logger.debug( - "Failed to forward SDK cancel for %s", inflight.request_id - ) - inflight.task.cancel() - else: - inflight.logical_cancelled = True - self._plugin_requests.pop(plugin_id, None) - - async def _handle_worker_closed(self, plugin_id: str) -> None: - if self._stopping: - return - await self._cancel_plugin_requests(plugin_id) - await self._close_temporary_mcp_sessions(plugin_id) - record = self._records.get(plugin_id) - if record is None: - return - await self._shutdown_local_mcp_servers(record) - record.session = None - if record.state in {SDK_STATE_RELOADING, SDK_STATE_DISABLED}: - return - if not record.restart_attempted: - record.restart_attempted = True - logger.warning( - "SDK plugin worker closed unexpectedly, retrying once: %s", - plugin_id, - ) - await self._load_or_reload_plugin( - record.plugin, - load_order=record.load_order, - reset_restart_budget=False, - ) - return - record.state = SDK_STATE_FAILED - self._http_routes.pop(plugin_id, None) - self._session_waiters.pop(plugin_id, None) - await self._unregister_schedule_jobs(plugin_id) - - def _record_to_dashboard_item(self, record: SdkPluginRecord) -> dict[str, Any]: - manifest = record.plugin.manifest_data - support_platforms = manifest.get("support_platforms") - installed_at = None - try: - installed_at = datetime.fromtimestamp( - record.plugin.plugin_dir.stat().st_mtime, - timezone.utc, - ).isoformat() - except OSError: - installed_at = None - handlers = [ - self._handler_to_dashboard_item(handler) for handler in record.handlers - ] - return { - "name": record.plugin_id, - "repo": "", - "author": str(manifest.get("author") or ""), - "desc": str(manifest.get("desc") or manifest.get("description") or ""), - "version": str(manifest.get("version") or "0.0.0"), - "reserved": False, - "activated": record.state not in {SDK_STATE_DISABLED, SDK_STATE_FAILED}, - "online_vesion": "", - "handlers": handlers, - "display_name": str(manifest.get("display_name") or record.plugin_id), - "logo": None, - "support_platforms": [ - str(item) for item in support_platforms if isinstance(item, str) - ] - if isinstance(support_platforms, list) - else [], - "astrbot_version": ( - str(manifest.get("astrbot_version")) - if manifest.get("astrbot_version") is not None - else "" - ), - "installed_at": installed_at, - "runtime_kind": "sdk", - "source_kind": "local_dir", - "managed_by": "sdk_bridge", - "state": record.state, - "trigger_summary": [item["cmd"] for item in handlers], - "unsupported_features": list(record.unsupported_features), - "failure_reason": record.failure_reason, - "issues": [dict(item) for item in record.issues], - } - - def _failed_issue_to_dashboard_item( - self, - plugin_id: str, - issues: list[dict[str, Any]], - ) -> dict[str, Any]: - issue = issues[0] if issues else {} - failure_reason = str(issue.get("details") or issue.get("message") or "") - return { - "name": plugin_id, - "repo": "", - "author": "", - "desc": str(issue.get("message", "")), - "version": "0.0.0", - "reserved": False, - "activated": False, - "online_vesion": "", - "handlers": [], - "display_name": plugin_id, - "logo": None, - "support_platforms": [], - "astrbot_version": "", - "installed_at": None, - "runtime_kind": "sdk", - "source_kind": "local_dir", - "managed_by": "sdk_bridge", - "state": SDK_STATE_FAILED, - "trigger_summary": [], - "unsupported_features": [], - "failure_reason": failure_reason, - "issues": [dict(item) for item in issues], - } - - def _handler_to_dashboard_item(self, handler: SdkHandlerRef) -> dict[str, Any]: - trigger = handler.descriptor.trigger - description = self._descriptor_description(handler.descriptor) - if not description and isinstance(trigger, CommandTrigger): - description = f"Command: {trigger.command}" - if not description: - description = "无描述" - if isinstance(trigger, CommandTrigger): - event_type = "SDKCommandEvent" - event_type_h = "SDK 指令触发" - elif isinstance(trigger, MessageTrigger): - event_type = "SDKMessageEvent" - event_type_h = "SDK 消息触发" - elif isinstance(trigger, EventTrigger): - event_type = "SDKEventTrigger" - event_type_h = "SDK 事件触发" - elif isinstance(trigger, ScheduleTrigger): - event_type = "SDKScheduleEvent" - event_type_h = "SDK 定时触发" - else: - event_type = "SDKHandler" - event_type_h = "SDK 行为触发" - - base = { - "event_type": event_type, - "event_type_h": event_type_h, - "handler_full_name": handler.handler_id, - "desc": description, - "handler_name": handler.handler_name, - "has_admin": handler.descriptor.permissions.require_admin, - } - if isinstance(trigger, CommandTrigger): - return {**base, "type": "指令", "cmd": trigger.command} - if isinstance(trigger, MessageTrigger): - if trigger.regex: - return {**base, "type": "正则匹配", "cmd": trigger.regex} - if trigger.keywords: - return {**base, "type": "关键词", "cmd": ", ".join(trigger.keywords)} - return {**base, "type": "消息", "cmd": "任意消息"} - if isinstance(trigger, EventTrigger): - return {**base, "type": "事件", "cmd": trigger.event_type} - if isinstance(trigger, ScheduleTrigger): - return { - **base, - "type": "定时", - "cmd": trigger.cron or str(trigger.interval_seconds), - } - return {**base, "type": "未知", "cmd": "未知"} - - def _load_state_overrides(self) -> dict[str, dict[str, Any]]: - if not self.state_path.exists(): - return {} - try: - data = json.loads(self.state_path.read_text(encoding="utf-8")) - except Exception: - return {} - plugins = data.get("plugins") - return dict(plugins) if isinstance(plugins, dict) else {} - - def _persist_state_overrides(self) -> None: - self.state_path.write_text( - json.dumps( - {"plugins": self._state_overrides}, ensure_ascii=False, indent=2 - ), - encoding="utf-8", - ) - - def _set_disabled_override(self, plugin_id: str, *, disabled: bool) -> None: - plugin_state = dict(self._state_overrides.get(plugin_id, {})) - if disabled: - plugin_state["disabled"] = True - self._state_overrides[plugin_id] = plugin_state - else: - plugin_state.pop("disabled", None) - if plugin_state: - self._state_overrides[plugin_id] = plugin_state - else: - self._state_overrides.pop(plugin_id, None) - self._persist_state_overrides() - - @staticmethod - def _normalize_http_route(route: str) -> str: - route_text = str(route).strip() - if not route_text: - raise AstrBotError.invalid_input("http route must not be empty") - if not route_text.startswith("/"): - route_text = f"/{route_text}" - return route_text - - @staticmethod - def _normalize_http_methods(methods: list[str]) -> tuple[str, ...]: - normalized = tuple( - sorted({str(method).upper() for method in methods if method}) - ) - if not normalized: - raise AstrBotError.invalid_input("http methods must not be empty") - return normalized - - def _ensure_http_route_available( - self, - *, - plugin_id: str, - route: str, - methods: tuple[str, ...], - ) -> None: - for legacy_route, _view_handler, legacy_methods, _desc in getattr( - self.star_context, "registered_web_apis", [] - ): - if route != legacy_route: - continue - if set(methods) & {str(method).upper() for method in legacy_methods}: - raise AstrBotError.invalid_input( - f"HTTP route conflict with legacy plugin route: {route}" - ) - for owner, entries in self._http_routes.items(): - for entry in entries: - if ( - owner == plugin_id - and entry.route == route - and entry.methods == methods - ): - continue - if entry.route != route: - continue - if set(entry.methods) & set(methods): - raise AstrBotError.invalid_input( - f"HTTP route conflict with SDK plugin route: {route}" - ) - - def _resolve_http_route( - self, - route: str, - method: str, - ) -> tuple[SdkPluginRecord, SdkHttpRoute] | None: - normalized_route = self._normalize_http_route(route) - normalized_method = str(method).upper() - for record in sorted(self._records.values(), key=lambda item: item.load_order): - for entry in self._http_routes.get(record.plugin_id, []): - if ( - entry.route == normalized_route - and normalized_method in entry.methods - ): - return record, entry - return None - - def _match_waiter_plugins(self, session_key: str) -> list[SdkPluginRecord]: - matches: list[SdkPluginRecord] = [] - for record in sorted(self._records.values(), key=lambda item: item.load_order): - if session_key in self._session_waiters.get(record.plugin_id, set()): - matches.append(record) - return matches - - async def _dispatch_waiter_event( - self, - event: AstrMessageEvent, - records: list[SdkPluginRecord], - ) -> SdkDispatchResult: - result = SdkDispatchResult() - dispatch_state = _DispatchState(event=event) - dispatch_token = self._get_dispatch_token(event) or uuid.uuid4().hex - self._bind_dispatch_token(event, dispatch_token) - overlay = self._ensure_request_overlay( - dispatch_token, - should_call_llm=not bool(getattr(event, "call_llm", False)), - ) - request_context = _RequestContext( - plugin_id="", - request_id="", - dispatch_token=dispatch_token, - dispatch_state=dispatch_state, - ) - self._request_contexts[dispatch_token] = request_context - for record in records: - if record.state in { - SDK_STATE_DISABLED, - SDK_STATE_FAILED, - SDK_STATE_RELOADING, - }: - continue - if record.session is None: - continue - whitelist = ( - None - if overlay.handler_whitelist is None - else set(overlay.handler_whitelist) - ) - if whitelist is not None and record.plugin_id not in whitelist: - continue - request_id = f"sdk_waiter_{record.plugin_id}_{uuid.uuid4().hex}" - request_context.plugin_id = record.plugin_id - request_context.request_id = request_id - request_context.cancelled = False - setattr(event, "_sdk_last_request_id", request_id) - payload = EventConverter.core_to_sdk( - event, - dispatch_token=dispatch_token, - plugin_id=record.plugin_id, - request_id=request_id, - ) - self._track_request_scope( - dispatch_token=dispatch_token, - request_id=request_id, - plugin_id=record.plugin_id, - ) - try: - output = await record.session.invoke_handler( - "__sdk_session_waiter__", - payload, - request_id=request_id, - args={}, - ) - except Exception as exc: - logger.warning( - "SDK waiter dispatch failed: plugin=%s error=%s", - record.plugin_id, - exc, - ) - output = {} - handler_result = EventConverter.extract_handler_result( - output if isinstance(output, dict) else {} - ) - result.executed_handlers.append( - {"plugin_id": record.plugin_id, "handler_id": "__sdk_session_waiter__"} - ) - dispatch_state.sent_message = ( - dispatch_state.sent_message or handler_result["sent_message"] - ) - dispatch_state.stopped = dispatch_state.stopped or handler_result["stop"] - if handler_result["call_llm"]: - overlay.requested_llm = True - overlay.should_call_llm = True - if handler_result["sent_message"] or handler_result["stop"]: - overlay.should_call_llm = False - if handler_result["stop"]: - break - result.sent_message = dispatch_state.sent_message - result.stopped = dispatch_state.stopped - if not result.executed_handlers: - result.skipped_reason = SKIP_NO_MATCH - if result.sent_message: - event._has_send_oper = True - overlay.should_call_llm = False - event.should_call_llm(True) - if result.stopped: - event.stop_event() - overlay.should_call_llm = False - event.should_call_llm(True) - return result diff --git a/astrbot/core/sdk_bridge/trigger_converter.py b/astrbot/core/sdk_bridge/trigger_converter.py deleted file mode 100644 index 1c52a228c..000000000 --- a/astrbot/core/sdk_bridge/trigger_converter.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -import inspect -import re -import shlex -import typing -from dataclasses import dataclass -from typing import Any, get_type_hints - -from astrbot_sdk.events import MessageEvent as SdkMessageEvent -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - CompositeFilterSpec, - HandlerDescriptor, - LocalFilterRefSpec, - MessageTrigger, - MessageTypeFilterSpec, - ParamSpec, - PlatformFilterSpec, -) - -from astrbot.core.platform.astr_message_event import AstrMessageEvent - - -@dataclass(slots=True) -class TriggerMatch: - plugin_id: str - handler_id: str - args: dict[str, Any] - priority: int - load_order: int - declaration_order: int - - -class TriggerConverter: - @staticmethod - def _message_type_name(event: AstrMessageEvent) -> str: - explicit = str(event.get_message_type().value).lower() - if explicit in {"group", "private", "other"}: - return explicit - if event.get_group_id(): - return "group" - if event.get_sender_id(): - return "private" - return "other" - - @staticmethod - def _match_command_name(text: str, command_name: str) -> str | None: - normalized = text.strip() - if normalized == command_name: - return "" - if normalized.startswith(f"{command_name} "): - return normalized[len(command_name) :].strip() - return None - - @staticmethod - def _split_command_remainder(remainder: str) -> list[str]: - try: - return shlex.split(remainder) - except ValueError: - return remainder.split() - - @classmethod - def _build_command_args(cls, handler, remainder: str) -> dict[str, Any]: - param_specs = getattr(handler, "param_specs", None) - if not isinstance(param_specs, list): - names = cls._legacy_arg_parameter_names(handler) - if not names or not remainder: - return {} - if len(names) == 1: - return {names[0]: remainder} - parts = cls._split_command_remainder(remainder) - return { - name: parts[index] - for index, name in enumerate(names) - if index < len(parts) - } - if not param_specs or not remainder: - return {} - if len(param_specs) == 1: - return {param_specs[0].name: remainder} - parts = cls._split_command_remainder(remainder) - args: dict[str, Any] = {} - for index, spec in enumerate(param_specs): - if index >= len(parts): - break - if spec.type == "greedy_str": - args[spec.name] = " ".join(parts[index:]) - break - args[spec.name] = parts[index] - return args - - @classmethod - def _build_regex_args(cls, handler, match: re.Match[str]) -> dict[str, Any]: - named = { - key: value for key, value in match.groupdict().items() if value is not None - } - param_specs = getattr(handler, "param_specs", None) - if isinstance(param_specs, list): - names = [spec.name for spec in param_specs if spec.name not in named] - else: - names = [ - name - for name in cls._legacy_arg_parameter_names(handler) - if name not in named - ] - positional = [value for value in match.groups() if value is not None] - for index, value in enumerate(positional): - if index >= len(names): - break - named[names[index]] = value - return named - - @classmethod - def _build_descriptor_command_args( - cls, - param_specs: list[ParamSpec], - remainder: str, - ) -> dict[str, Any]: - if not param_specs or not remainder: - return {} - if len(param_specs) == 1: - return {param_specs[0].name: remainder} - parts = cls._split_command_remainder(remainder) - args: dict[str, Any] = {} - for index, spec in enumerate(param_specs): - if index >= len(parts): - break - if spec.type == "greedy_str": - args[spec.name] = " ".join(parts[index:]) - break - args[spec.name] = parts[index] - return args - - @classmethod - def _build_descriptor_regex_args( - cls, - param_specs: list[ParamSpec], - match: re.Match[str], - ) -> dict[str, Any]: - named = { - key: value for key, value in match.groupdict().items() if value is not None - } - names = [spec.name for spec in param_specs if spec.name not in named] - positional = [value for value in match.groups() if value is not None] - for index, value in enumerate(positional): - if index >= len(names): - break - named[names[index]] = value - return named - - @classmethod - def _match_filters( - cls, - descriptor: HandlerDescriptor, - event: AstrMessageEvent, - ) -> bool: - for filter_spec in descriptor.filters: - if not cls._match_filter_spec(filter_spec, event): - return False - return True - - @classmethod - def _match_filter_spec(cls, filter_spec, event: AstrMessageEvent) -> bool: - if isinstance(filter_spec, PlatformFilterSpec): - return event.get_platform_name() in filter_spec.platforms - if isinstance(filter_spec, MessageTypeFilterSpec): - return cls._message_type_name(event) in filter_spec.message_types - if isinstance(filter_spec, LocalFilterRefSpec): - # Local filter refs point at plugin-process callables. The host bridge - # cannot execute them, so trigger matching must stay fail-open here. - return True - if isinstance(filter_spec, CompositeFilterSpec): - results = [ - cls._match_filter_spec(child, event) for child in filter_spec.children - ] - if filter_spec.kind == "and": - return all(results) - return any(results) - return True - - @classmethod - def _legacy_arg_parameter_names(cls, handler) -> list[str]: - try: - signature = inspect.signature(handler) - except (TypeError, ValueError): - return [] - try: - type_hints = get_type_hints(handler) - except Exception: - type_hints = {} - names: list[str] = [] - for parameter in signature.parameters.values(): - if parameter.kind not in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - continue - if cls._is_injected_parameter( - parameter.name, type_hints.get(parameter.name) - ): - continue - names.append(parameter.name) - return names - - @classmethod - def _is_injected_parameter(cls, name: str, annotation: Any) -> bool: - if name in {"event", "ctx", "context"}: - return True - normalized = cls._unwrap_optional(annotation) - if normalized is None: - return False - if normalized in {AstrMessageEvent, SdkMessageEvent}: - return True - if isinstance(normalized, type) and issubclass( - normalized, - (AstrMessageEvent, SdkMessageEvent), - ): - return True - return False - - @staticmethod - def _unwrap_optional(annotation: Any) -> Any: - if annotation is None: - return None - origin = typing.get_origin(annotation) - if origin is typing.Union: - options = [ - item for item in typing.get_args(annotation) if item is not type(None) - ] - if len(options) == 1: - return options[0] - return annotation - - @classmethod - def match_handler( - cls, - *, - plugin_id: str, - handler=None, - descriptor: HandlerDescriptor, - event: AstrMessageEvent, - load_order: int, - declaration_order: int, - ) -> TriggerMatch | None: - trigger = descriptor.trigger - - if descriptor.permissions.require_admin and not event.is_admin(): - return None - if not cls._match_filters(descriptor, event): - return None - - if isinstance(trigger, CommandTrigger): - text = event.get_message_str().strip() - for command_name in [trigger.command, *trigger.aliases]: - if not command_name: - continue - remainder = cls._match_command_name(text, command_name) - if remainder is None: - continue - return TriggerMatch( - plugin_id=plugin_id, - handler_id=descriptor.id, - args=( - cls._build_command_args(handler, remainder) - if handler is not None - else cls._build_descriptor_command_args( - descriptor.param_specs, - remainder, - ) - ), - priority=descriptor.priority, - load_order=load_order, - declaration_order=declaration_order, - ) - return None - - if isinstance(trigger, MessageTrigger): - text = event.get_message_str() - if trigger.regex: - match = re.search(trigger.regex, text) - if match is None: - return None - args = ( - cls._build_regex_args(handler, match) if handler is not None else {} - ) - if handler is None: - args = cls._build_descriptor_regex_args( - descriptor.param_specs, match - ) - else: - if trigger.keywords and not any( - keyword in text for keyword in trigger.keywords - ): - return None - args = {} - return TriggerMatch( - plugin_id=plugin_id, - handler_id=descriptor.id, - args=args, - priority=descriptor.priority, - load_order=load_order, - declaration_order=declaration_order, - ) - - return None - - @staticmethod - def sort_key(match: TriggerMatch) -> tuple[int, int, int]: - return (-match.priority, match.load_order, match.declaration_order) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 6ddd184b7..bc72bcaec 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -18,12 +18,10 @@ from astrbot.core.utils.astrbot_path import AstrbotPaths, astrbot_paths SKILLS_CONFIG_FILENAME = "skills.json" SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json" -SDK_PLUGIN_SKILLS_FILENAME = "sdk_plugin_skills.json" DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}} SANDBOX_SKILLS_ROOT = "skills" SANDBOX_WORKSPACE_ROOT = "/workspace" _SANDBOX_SKILLS_CACHE_VERSION = 1 -_SDK_PLUGIN_SKILLS_VERSION = 1 _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") @@ -94,18 +92,8 @@ class SkillInfo: output_schema: dict | None = None -@dataclass(frozen=True, slots=True) -class LocalSkillSource: - name: str - skill_dir: Path - skill_md_path: Path - owner_type: str = "standalone" - description_override: str = "" - plugin_id: str | None = None - - -def _parse_frontmatter_description(text: str) -> str: - """Extract the ``description`` value from YAML frontmatter. +def _parse_frontmatter(text: str) -> dict: + """Extract metadata from YAML frontmatter. Expects the standard SKILL.md format used by OpenAI Codex CLI and Anthropic Claude Skills:: @@ -294,218 +282,6 @@ class SkillManager: ) os.makedirs(self.skills_root, exist_ok=True) - def _read_skill_description(self, skill_md_path: Path) -> str: - try: - content = skill_md_path.read_text(encoding="utf-8") - except Exception: - return "" - return _parse_frontmatter_description(content) - - def _discover_standalone_skill_sources(self) -> dict[str, LocalSkillSource]: - sources: dict[str, LocalSkillSource] = {} - skills_root = Path(self.skills_root) - if not skills_root.exists(): - return sources - - for entry in sorted(skills_root.iterdir()): - if not entry.is_dir(): - continue - skill_md_path = entry / "SKILL.md" - if not skill_md_path.is_file(): - continue - sources[entry.name] = LocalSkillSource( - name=entry.name, - skill_dir=entry, - skill_md_path=skill_md_path, - owner_type="standalone", - ) - return sources - - def _load_sdk_plugin_skills_registry(self) -> dict[str, object]: - if not os.path.exists(self.sdk_plugin_skills_path): - return {"version": _SDK_PLUGIN_SKILLS_VERSION, "plugins": {}} - try: - with open(self.sdk_plugin_skills_path, encoding="utf-8") as f: - data = json.load(f) - except Exception: - return {"version": _SDK_PLUGIN_SKILLS_VERSION, "plugins": {}} - if not isinstance(data, dict): - return {"version": _SDK_PLUGIN_SKILLS_VERSION, "plugins": {}} - plugins = data.get("plugins", {}) - if not isinstance(plugins, dict): - plugins = {} - return { - "version": int(data.get("version", _SDK_PLUGIN_SKILLS_VERSION)), - "plugins": plugins, - } - - def _save_sdk_plugin_skills_registry(self, registry: dict[str, object]) -> None: - registry["version"] = _SDK_PLUGIN_SKILLS_VERSION - with open(self.sdk_plugin_skills_path, "w", encoding="utf-8") as f: - json.dump(registry, f, ensure_ascii=False, indent=2) - - def replace_sdk_plugin_skills( - self, - plugin_id: str, - skills: list[dict[str, str]], - ) -> None: - plugin_name = str(plugin_id).strip() - if not plugin_name: - raise ValueError("plugin_id must not be empty") - - normalized_skills: list[dict[str, str]] = [] - for item in skills: - if not isinstance(item, dict): - continue - skill_name = str(item.get("name", "")).strip() - skill_dir_text = str(item.get("skill_dir", "")).strip() - if not skill_name or not _SKILL_NAME_RE.fullmatch(skill_name): - continue - if not skill_dir_text: - continue - skill_dir = Path(skill_dir_text).resolve() - skill_md_path = Path( - str(item.get("path", "")).strip() or str(skill_dir / "SKILL.md") - ).resolve() - normalized_skills.append( - { - "name": skill_name, - "description": str(item.get("description", "") or ""), - "path": str(skill_md_path), - "skill_dir": str(skill_dir), - } - ) - - registry = self._load_sdk_plugin_skills_registry() - plugins = registry.get("plugins", {}) - if not isinstance(plugins, dict): - plugins = {} - previous_items = plugins.get(plugin_name, []) - previous_names = { - str(item.get("name", "")).strip() - for item in previous_items - if isinstance(item, dict) - } - if normalized_skills: - plugins[plugin_name] = sorted( - normalized_skills, - key=lambda item: str(item.get("name", "")), - ) - else: - plugins.pop(plugin_name, None) - registry["plugins"] = plugins - self._save_sdk_plugin_skills_registry(registry) - - current_names = {item["name"] for item in normalized_skills} - for removed_name in sorted(previous_names - current_names): - self._remove_skill_from_sandbox_cache(removed_name) - - def remove_sdk_plugin_skills(self, plugin_id: str) -> None: - self.replace_sdk_plugin_skills(plugin_id, []) - - def prune_sdk_plugin_skills(self, active_plugin_ids: set[str]) -> None: - normalized_ids = { - str(item).strip() for item in active_plugin_ids if str(item).strip() - } - registry = self._load_sdk_plugin_skills_registry() - plugins = registry.get("plugins", {}) - if not isinstance(plugins, dict): - return - - removed_skill_names: set[str] = set() - updated_plugins: dict[str, object] = {} - for plugin_id, items in plugins.items(): - plugin_name = str(plugin_id).strip() - if not plugin_name: - continue - if plugin_name in normalized_ids: - updated_plugins[plugin_name] = items - continue - if isinstance(items, list): - removed_skill_names.update( - str(item.get("name", "")).strip() - for item in items - if isinstance(item, dict) - ) - - registry["plugins"] = updated_plugins - self._save_sdk_plugin_skills_registry(registry) - for removed_name in sorted(name for name in removed_skill_names if name): - self._remove_skill_from_sandbox_cache(removed_name) - - def _discover_sdk_plugin_skill_sources(self) -> dict[str, LocalSkillSource]: - sources: dict[str, LocalSkillSource] = {} - registry = self._load_sdk_plugin_skills_registry() - plugins = registry.get("plugins", {}) - if not isinstance(plugins, dict): - return sources - for plugin_id, items in plugins.items(): - if not isinstance(items, list): - continue - for item in items: - if not isinstance(item, dict): - continue - skill_name = str(item.get("name", "")).strip() - skill_dir_text = str(item.get("skill_dir", "")).strip() - path_text = str(item.get("path", "")).strip() - if not skill_name or not _SKILL_NAME_RE.fullmatch(skill_name): - continue - if not skill_dir_text: - continue - skill_dir = Path(skill_dir_text) - skill_md_path = Path(path_text or str(skill_dir / "SKILL.md")) - if not skill_dir.is_dir() or not skill_md_path.is_file(): - continue - sources.setdefault( - skill_name, - LocalSkillSource( - name=skill_name, - skill_dir=skill_dir, - skill_md_path=skill_md_path, - owner_type="sdk_registered", - description_override=str(item.get("description", "") or ""), - plugin_id=str(plugin_id), - ), - ) - return sources - - def list_local_skill_sources(self) -> list[LocalSkillSource]: - sources = self._discover_standalone_skill_sources() - for name, source in self._discover_sdk_plugin_skill_sources().items(): - sources.setdefault(name, source) - return [sources[name] for name in sorted(sources)] - - def get_local_skill_source(self, name: str) -> LocalSkillSource | None: - for source in self.list_local_skill_sources(): - if source.name == name: - return source - return None - - def materialize_local_skill_bundle( - self, - bundle_root: Path, - *, - skill_names: list[str] | None = None, - ) -> list[LocalSkillSource]: - selected_names = ( - {name for name in skill_names if name} if skill_names is not None else None - ) - bundle_root.mkdir(parents=True, exist_ok=True) - - copied_sources: list[LocalSkillSource] = [] - for source in self.list_local_skill_sources(): - if selected_names is not None and source.name not in selected_names: - continue - target_dir = bundle_root / source.name - if target_dir.exists(): - shutil.rmtree(target_dir) - # SDK-registered skills may live inside plugin packages, so bundle - # them under the public skill id to give sandbox/runtime a stable - # path that is independent from the plugin's internal layout. - shutil.copytree(source.skill_dir, target_dir) - copied_sources.append(source) - return copied_sources - def _load_config(self) -> dict: if not os.path.exists(self.config_path): self._save_config(DEFAULT_SKILLS_CONFIG.copy()) @@ -652,7 +428,7 @@ class SkillManager: skill_name ) or _default_sandbox_skill_path(skill_name) else: - path_str = str(source.skill_md_path) + path_str = str(skill_md) path_str = path_str.replace("\\", "/") skills_by_name[skill_name] = SkillInfo( name=skill_name, @@ -710,9 +486,8 @@ class SkillManager: return [skills_by_name[name] for name in sorted(skills_by_name)] def is_sandbox_only_skill(self, name: str) -> bool: - if self.get_local_skill_source(name) is not None: - skill_dir = Path(self.skills_root) / name - skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None + skill_dir = Path(self.skills_root) / name + skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None if skill_md_exists: return False cache = self._load_sandbox_skills_cache() @@ -760,14 +535,9 @@ class SkillManager: "Sandbox preset skill cannot be deleted from local skill management." ) - source = self.get_local_skill_source(name) - if source is not None and source.owner_type != "standalone": - raise PermissionError( - "SDK-registered skill cannot be deleted here. Disable or update the owning plugin instead." - ) - - if source is not None and source.skill_dir.exists(): - shutil.rmtree(source.skill_dir) + skill_dir = Path(self.skills_root) / name + if skill_dir.exists(): + shutil.rmtree(skill_dir) # Ensure UI consistency even when there is no active sandbox session # to refresh cache from runtime side. diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 22a4017ee..2591e3eca 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging from asyncio import Queue from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, cast from deprecated import deprecated @@ -18,9 +18,13 @@ from astrbot.core.db import BaseDatabase from astrbot.core.exceptions import ProviderNotFoundError from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.platform.astr_message_event import MessageSesion +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_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager +from astrbot.core.provider.manager import ProviderManager from astrbot.core.provider.provider import ( EmbeddingProvider, Provider, @@ -32,6 +36,7 @@ from astrbot.core.star.filter.platform_adapter_type import ( ADAPTER_NAME_2_TYPE, PlatformAdapterType, ) +from astrbot.core.subagent_orchestrator import SubAgentOrchestrator from .filter.command import CommandFilter from .filter.regex import RegexFilter @@ -41,19 +46,7 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry logger = logging.getLogger("astrbot") if TYPE_CHECKING: - 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.persona_mgr import PersonaManager - from astrbot.core.platform import Platform - from astrbot.core.platform.astr_message_event import AstrMessageEvent - from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager - from astrbot.core.provider.manager import ProviderManager - from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge - from astrbot.core.subagent_orchestrator import SubAgentOrchestrator class PlatformManagerProtocol(Protocol): @@ -113,8 +106,6 @@ class Context: self.cron_manager = cron_manager """Cron job manager, initialized by core lifecycle.""" self.subagent_orchestrator = subagent_orchestrator - self.sdk_plugin_bridge: SdkPluginBridge | None = None - """SDK plugin bridge, initialized by core lifecycle when available.""" # Register built-in tools so they appear in WebUI and can be # assigned to subagents. Done here (not at module-import time) @@ -171,10 +162,12 @@ class Context: image_urls: list[str] | None = None, tools: ToolSet | None = None, system_prompt: str | None = None, - contexts: list[Message | dict[str, Any]] | None = None, + contexts: list[Message] | None = None, max_steps: int = 30, tool_call_timeout: int = 120, stream: bool = False, + agent_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None, + agent_context: AstrAgentContext | None = None, **kwargs: Any, ) -> LLMResponse: """Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced. @@ -352,10 +345,6 @@ class Context: """获取所有用于 Embedding 任务的 Provider。""" return self.provider_manager.embedding_provider_insts - def get_all_rerank_providers(self) -> list[RerankProvider]: - """获取所有用于 Rerank 任务的 Provider。""" - return self.provider_manager.rerank_provider_insts - def get_using_provider(self, umo: str | None = None) -> Provider | None: """获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。 @@ -397,7 +386,7 @@ class Context: ) if prov and not isinstance(prov, TTSProvider): raise ValueError("返回的 Provider 不是 TTSProvider 类型") - return prov + return cast(TTSProvider | None, prov) def get_using_stt_provider(self, umo: str | None = None) -> STTProvider | None: """获取当前使用的用于 STT 任务的 Provider。 @@ -417,7 +406,7 @@ class Context: ) if prov and not isinstance(prov, STTProvider): raise ValueError("返回的 Provider 不是 STTProvider 类型") - return prov + return cast(STTProvider | None, prov) def get_config(self, umo: str | None = None) -> AstrBotConfig: """获取 AstrBot 的配置。 @@ -466,34 +455,6 @@ class Context: for platform in self.platform_manager.platform_insts: if platform.meta().id == session.platform_name: await platform.send_by_session(session, message_chain) - if self.sdk_plugin_bridge is not None: - from astrbot_sdk.message.components import component_to_payload_sync - - try: - await self.sdk_plugin_bridge.dispatch_system_event( - "after_message_sent", - { - "session_id": str(session), - "platform": platform.meta().name, - "platform_id": platform.meta().id, - "message_type": session.message_type.value, - "message_outline": message_chain.get_plain_text( - with_other_comps_mark=True - ), - "sent_message_outline": message_chain.get_plain_text( - with_other_comps_mark=True - ), - "sent_messages": [ - component_to_payload_sync(component) - for component in message_chain.chain - ], - }, - ) - except Exception as exc: - logger.warning( - "SDK after_message_sent dispatch failed for proactive send: %s", - exc, - ) return True logger.warning( f"cannot find platform for session {session!s}, message not sent" diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py index acd18544d..aae82e1b1 100644 --- a/astrbot/dashboard/routes/command.py +++ b/astrbot/dashboard/routes/command.py @@ -1,6 +1,5 @@ from quart import request -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.star.command_management import ( list_command_conflicts, list_commands, @@ -19,13 +18,8 @@ from .route import Response, Route, RouteContext class CommandRoute(Route): - def __init__( - self, - context: RouteContext, - core_lifecycle: AstrBotCoreLifecycle, - ) -> None: + def __init__(self, context: RouteContext) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle self.routes = { "/commands": ("GET", self.get_commands), "/commands/conflicts": ("GET", self.get_conflicts), @@ -36,7 +30,7 @@ class CommandRoute(Route): self.register_routes() async def get_commands(self): - commands = await _list_dashboard_commands(self.core_lifecycle) + commands = await list_commands() summary = { "total": len(commands), "disabled": len([cmd for cmd in commands if not cmd["enabled"]]), @@ -50,153 +44,62 @@ class CommandRoute(Route): async def toggle_command(self): data = await request.get_json() - command_key = _resolve_command_key(data) + handler_full_name = data.get("handler_full_name") enabled = data.get("enabled") - if command_key is None or enabled is None: - return Response().error("command_key 与 enabled 均为必填。").to_json() + if handler_full_name is None or enabled is None: + return Response().error("handler_full_name 与 enabled 均为必填。").__dict__ if isinstance(enabled, str): enabled = enabled.lower() in ("1", "true", "yes", "on") - item = await _get_command_payload(self.core_lifecycle, command_key) - if item.get("runtime_kind") == "sdk": - return ( - Response() - .error("SDK commands are read-only in the dashboard.") - .__dict__ - ) - try: - await toggle_command_service(command_key, bool(enabled)) + await toggle_command_service(handler_full_name, bool(enabled)) except ValueError as exc: return Response().error(str(exc)).__dict__ - payload = await _get_command_payload(self.core_lifecycle, command_key) + payload = await _get_command_payload(handler_full_name) return Response().ok(payload).__dict__ async def rename_command(self): data = await request.get_json() - command_key = _resolve_command_key(data) + handler_full_name = data.get("handler_full_name") new_name = data.get("new_name") aliases = data.get("aliases") - if not command_key or not new_name: - return Response().error("command_key 与 new_name 均为必填。").__dict__ - - item = await _get_command_payload(self.core_lifecycle, command_key) - if item.get("runtime_kind") == "sdk": - return ( - Response() - .error("SDK commands are read-only in the dashboard.") - .to_json() - ) + if not handler_full_name or not new_name: + return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ try: - await rename_command_service(command_key, new_name, aliases=aliases) + await rename_command_service(handler_full_name, new_name, aliases=aliases) except ValueError as exc: return Response().error(str(exc)).__dict__ - payload = await _get_command_payload(self.core_lifecycle, command_key) + payload = await _get_command_payload(handler_full_name) return Response().ok(payload).__dict__ async def update_permission(self): data = await request.get_json() - command_key = _resolve_command_key(data) + handler_full_name = data.get("handler_full_name") permission = data.get("permission") - if not command_key or not permission: - return Response().error("command_key 与 permission 均为必填。").__dict__ - - item = await _get_command_payload(self.core_lifecycle, command_key) - if item.get("runtime_kind") == "sdk": + if not handler_full_name or not permission: return ( - Response() - .error("SDK commands are read-only in the dashboard.") - .to_json() + Response().error("handler_full_name 与 permission 均为必填。").__dict__ ) try: - await update_command_permission_service(command_key, permission) + await update_command_permission_service(handler_full_name, permission) except ValueError as exc: return Response().error(str(exc)).__dict__ - payload = await _get_command_payload(self.core_lifecycle, command_key) + payload = await _get_command_payload(handler_full_name) return Response().ok(payload).__dict__ -def _resolve_command_key(data: dict | None) -> str | None: - if not isinstance(data, dict): - return None - command_key = data.get("command_key") - if command_key: - return str(command_key) - handler_full_name = data.get("handler_full_name") - if handler_full_name: - return str(handler_full_name) - return None - - -async def _list_dashboard_commands( - core_lifecycle: AstrBotCoreLifecycle, -) -> list[dict]: - commands = _decorate_legacy_commands(await list_commands()) - sdk_bridge = getattr(core_lifecycle, "sdk_plugin_bridge", None) - if sdk_bridge is not None: - commands.extend(sdk_bridge.list_dashboard_commands()) - _apply_conflict_flags(commands) - commands.sort(key=lambda item: str(item.get("effective_command", "")).lower()) - return commands - - -def _decorate_legacy_commands(commands: list[dict]) -> list[dict]: - for item in commands: - _decorate_legacy_command_item(item) - return commands - - -def _decorate_legacy_command_item(item: dict) -> None: - item["command_key"] = str(item.get("handler_full_name", "")) - item["runtime_kind"] = "legacy" - item["supports_toggle"] = True - item["supports_rename"] = True - item["supports_permission"] = True - sub_commands = item.get("sub_commands") - if not isinstance(sub_commands, list): - return - for sub in sub_commands: - if isinstance(sub, dict): - _decorate_legacy_command_item(sub) - - -def _apply_conflict_flags(commands: list[dict]) -> None: - counts: dict[str, int] = {} - for item in _walk_command_items(commands): - command_name = str(item.get("effective_command", "")).strip() - if not command_name or not bool(item.get("enabled", False)): - continue - counts[command_name] = counts.get(command_name, 0) + 1 - - for item in _walk_command_items(commands): - command_name = str(item.get("effective_command", "")).strip() - item["has_conflict"] = bool(command_name and counts.get(command_name, 0) > 1) - - -def _walk_command_items(commands: list[dict]): - for item in commands: - yield item - sub_commands = item.get("sub_commands") - if not isinstance(sub_commands, list): - continue - yield from _walk_command_items(sub_commands) - - -async def _get_command_payload( - core_lifecycle: AstrBotCoreLifecycle, - command_key: str, -): - commands = await _list_dashboard_commands(core_lifecycle) - for cmd in _walk_command_items(commands): - if cmd.get("command_key") == command_key: +async def _get_command_payload(handler_full_name: str): + commands = await list_commands() + for cmd in commands: + if cmd["handler_full_name"] == handler_full_name: return cmd return {} diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 17dd7f6ab..97509def8 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -1063,7 +1063,7 @@ class ConfigRoute(Route): plugin_name = request.args.get("plugin_name", "unknown") try: await self._save_plugin_configs(post_configs, plugin_name) - await self._reload_plugin_after_config_save(plugin_name) + await self.core_lifecycle.plugin_manager.reload(plugin_name) return ( Response() .ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。") @@ -1078,16 +1078,6 @@ class ConfigRoute(Route): return plugin_md return None - def _sdk_bridge(self): - return getattr(self.core_lifecycle, "sdk_plugin_bridge", None) - - async def _reload_plugin_after_config_save(self, plugin_name: str) -> None: - sdk_bridge = self._sdk_bridge() - if sdk_bridge is not None and sdk_bridge.get_plugin_metadata(plugin_name): - await sdk_bridge.reload_plugin(plugin_name) - return - await self.core_lifecycle.plugin_manager.reload(plugin_name) - def _resolve_config_file_scope( self, ) -> tuple[str, str, str, StarMetadata, AstrBotConfig]: @@ -1544,26 +1534,6 @@ class ConfigRoute(Route): } break - if ret["metadata"] is not None: - return ret - - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: - return ret - - schema = sdk_bridge.get_plugin_config_schema(plugin_name) - if schema is None or not schema: - return ret - config = sdk_bridge.get_plugin_config(plugin_name) or {} - ret["config"] = config - ret["metadata"] = { - plugin_name: { - "description": f"{plugin_name} 配置", - "type": "object", - "items": schema, - }, - } - return ret async def _save_astrbot_configs( @@ -1590,40 +1560,18 @@ class ConfigRoute(Route): if plugin_md.name == plugin_name: md = plugin_md - if md: - if not md.config: - raise ValueError(f"插件 {plugin_name} 没有注册配置") - assert md.config is not None - - try: - errors, post_configs = validate_config( - post_configs, getattr(md.config, "schema", {}), is_core=False - ) - if errors: - raise ValueError(f"格式校验未通过: {errors}") - md.config.save_config(post_configs) - return - except Exception as e: - raise e - - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: + if not md: raise ValueError(f"插件 {plugin_name} 不存在") - - schema = sdk_bridge.get_plugin_config_schema(plugin_name) - if schema is None: - raise ValueError(f"插件 {plugin_name} 不存在") - if not schema: + if not md.config: raise ValueError(f"插件 {plugin_name} 没有注册配置") + assert md.config is not None try: errors, post_configs = validate_config( - post_configs, - schema, - is_core=False, + post_configs, getattr(md.config, "schema", {}), is_core=False ) if errors: raise ValueError(f"格式校验未通过: {errors}") - sdk_bridge.save_plugin_config(plugin_name, post_configs) + md.config.save_config(post_configs) except Exception as e: raise e diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index f878e1c38..d4a184293 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -88,17 +88,6 @@ class PluginRoute(Route): self._logo_cache = {} - def _sdk_bridge(self): - return getattr(self.core_lifecycle, "sdk_plugin_bridge", None) - - def _is_sdk_plugin(self, plugin_name: str) -> bool: - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: - return False - return any( - plugin["name"] == plugin_name for plugin in sdk_bridge.list_plugins() - ) - async def check_plugin_compatibility(self): try: data = await request.get_json() @@ -158,20 +147,10 @@ class PluginRoute(Route): data = await request.get_json() plugin_name = data.get("name", None) try: - if plugin_name and self._is_sdk_plugin(plugin_name): - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: - return Response().error("SDK bridge 未初始化").__dict__ - await sdk_bridge.reload_plugin(plugin_name) - else: - success, message = await self.plugin_manager.reload(plugin_name) - if not success: - return Response().error(message or "插件重载失败").__dict__ - if plugin_name is None: - sdk_bridge = self._sdk_bridge() - if sdk_bridge is not None: - await sdk_bridge.reload_all(reset_restart_budget=True) - return Response().ok(None, "重载成功。").__dict__ + success, message = await self.plugin_manager.reload(plugin_name) + if not success: + return Response().error(message or "插件重载失败").__dict__ + return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") return Response().error(str(e)).__dict__ @@ -448,12 +427,6 @@ class PluginRoute(Route): ): continue _plugin_resp.append(_t) - sdk_bridge = self._sdk_bridge() - if sdk_bridge is not None: - for plugin in sdk_bridge.list_plugins(): - if plugin_name and plugin["name"] != plugin_name: - continue - _plugin_resp.append(plugin) return ( Response() .ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info) @@ -617,10 +590,6 @@ class PluginRoute(Route): plugin_name = post_data["name"] delete_config = post_data.get("delete_config", False) delete_data = post_data.get("delete_data", False) - if self._is_sdk_plugin(plugin_name): - return Response().error( - "SDK 插件在 MVP 中不支持卸载,请手动移除目录" - ).__dict__, 400 try: logger.info(f"正在卸载插件 {plugin_name}") await self.plugin_manager.uninstall_plugin( @@ -673,8 +642,6 @@ class PluginRoute(Route): post_data = await request.get_json() plugin_name = post_data["name"] proxy: str = post_data.get("proxy", None) - if self._is_sdk_plugin(plugin_name): - return Response().error("SDK 插件在 MVP 中不支持更新").__dict__, 400 try: logger.info(f"正在更新插件 {plugin_name}") await self.plugin_manager.update_plugin(plugin_name, proxy) @@ -749,16 +716,6 @@ class PluginRoute(Route): post_data = await request.get_json() plugin_name = post_data["name"] - if self._is_sdk_plugin(plugin_name): - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: - return Response().error("SDK bridge 未初始化").__dict__, 500 - try: - await sdk_bridge.turn_off_plugin(plugin_name) - except ValueError as exc: - return Response().error(str(exc)).__dict__, 404 - logger.info(f"停用 SDK 插件 {plugin_name} 。") - return Response().ok(None, "停用成功。").__dict__ try: await self.plugin_manager.turn_off_plugin(plugin_name) logger.info(f"停用插件 {plugin_name} 。") @@ -777,16 +734,6 @@ class PluginRoute(Route): post_data = await request.get_json() plugin_name = post_data["name"] - if self._is_sdk_plugin(plugin_name): - sdk_bridge = self._sdk_bridge() - if sdk_bridge is None: - return Response().error("SDK bridge 未初始化").__dict__, 500 - try: - await sdk_bridge.turn_on_plugin(plugin_name) - except ValueError as exc: - return Response().error(str(exc)).__dict__, 404 - logger.info(f"启用 SDK 插件 {plugin_name} 。") - return Response().ok(None, "启用成功。").__dict__ try: await self.plugin_manager.turn_on_plugin(plugin_name) logger.info(f"启用插件 {plugin_name} 。") diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index d0489f632..e7d9a4c94 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -317,28 +317,24 @@ class SkillsRoute(Route): .__dict__ ) - if skill_mgr.get_local_skill_source(name) is None: + skill_dir = Path(skill_mgr.skills_root) / name + skill_md = skill_dir / "SKILL.md" + if not skill_dir.is_dir() or not skill_md.exists(): return Response().error("Local skill not found").__dict__ export_dir = Path(get_astrbot_temp_path()) / "skill_exports" export_dir.mkdir(parents=True, exist_ok=True) zip_base = export_dir / name zip_path = zip_base.with_suffix(".zip") - bundle_dir = export_dir / f"{name}_{uuid.uuid4().hex}" if zip_path.exists(): zip_path.unlink() - try: - skill_mgr.materialize_local_skill_bundle(bundle_dir, skill_names=[name]) - shutil.make_archive( - str(zip_base), - "zip", - root_dir=str(bundle_dir), - base_dir=name, - ) - finally: - if bundle_dir.exists(): - shutil.rmtree(bundle_dir, ignore_errors=True) + shutil.make_archive( + str(zip_base), + "zip", + root_dir=str(skill_mgr.skills_root), + base_dir=name, + ) return await send_file( str(zip_path), diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 1f9a032ab..067139804 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -451,21 +451,15 @@ class ToolsRoute(Route): origin_name = "unknown" tool_info = { - "tool_key": _build_legacy_tool_key(tool, origin, origin_name), "name": tool.name, "description": tool.description, "parameters": tool.parameters, "active": tool.active, "origin": origin, "origin_name": origin_name, - "runtime_kind": "legacy", - "plugin_id": None, "source": source, } tools_dict.append(tool_info) - sdk_bridge = getattr(self.core_lifecycle, "sdk_plugin_bridge", None) - if sdk_bridge is not None: - tools_dict.extend(sdk_bridge.list_dashboard_tools()) return Response().ok(data=tools_dict).__dict__ except Exception as e: logger.error(traceback.format_exc()) @@ -476,45 +470,15 @@ class ToolsRoute(Route): try: data = await request.json tool_name = data.get("name") - tool_key = data.get("tool_key") action = data.get("activate") # True or False - runtime_kind = str(data.get("runtime_kind", "legacy") or "legacy") - plugin_id = data.get("plugin_id") - if (not tool_name and not tool_key) or action is None: + if not tool_name or action is None: return ( Response() - .error("Missing required parameters: tool_key/name or activate") + .error("Missing required parameters: name or activate") .__dict__ ) - if runtime_kind == "sdk": - sdk_bridge = getattr(self.core_lifecycle, "sdk_plugin_bridge", None) - if sdk_bridge is None: - return Response().error("SDK bridge is unavailable.").__dict__ - if not plugin_id or not tool_name: - return ( - Response() - .error("SDK tool toggle requires plugin_id and name") - .__dict__ - ) - plugin_metadata = sdk_bridge.get_plugin_metadata(str(plugin_id)) - if ( - action - and plugin_metadata is not None - and not plugin_metadata.get("enabled", False) - ): - return ( - Response() - .error( - "The SDK plugin is disabled. Enable the plugin before activating its tool." - ) - .__dict__ - ) - if action: - ok = sdk_bridge.activate_llm_tool(str(plugin_id), str(tool_name)) - else: - ok = sdk_bridge.deactivate_llm_tool(str(plugin_id), str(tool_name)) # Internal tools cannot be toggled by users for t in self.tool_mgr.func_list: if t.name == tool_name and getattr(t, "source", "") == "internal": @@ -526,25 +490,13 @@ class ToolsRoute(Route): except ValueError as e: return Response().error(f"Failed to activate tool: {e!s}").__dict__ else: - if action: - try: - ok = self.tool_mgr.activate_llm_tool( - str(tool_name), star_map=star_map - ) - except ValueError as e: - return ( - Response().error(f"Failed to activate tool: {e!s}").__dict__ - ) - else: - ok = self.tool_mgr.deactivate_llm_tool(str(tool_name)) + ok = self.tool_mgr.deactivate_llm_tool(tool_name) if ok: return Response().ok(None, "Operation successful.").__dict__ return ( Response() - .error( - f"Tool {tool_key or tool_name} does not exist or the operation failed." - ) + .error(f"Tool {tool_name} does not exist or the operation failed.") .__dict__ ) @@ -570,11 +522,3 @@ class ToolsRoute(Route): except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"Sync failed: {e!s}").__dict__ - - -def _build_legacy_tool_key(tool, origin: str, origin_name: str) -> str: - if origin == "mcp" and origin_name: - return f"mcp:{origin_name}:{tool.name}" - if origin == "plugin" and getattr(tool, "handler_module_path", None): - return f"plugin:{tool.handler_module_path}:{tool.name}" - return f"tool:{tool.name}" diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 7c62f4015..f01a04479 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -19,7 +19,6 @@ from flask.json.provider import DefaultJSONProvider from hypercorn.asyncio import serve from hypercorn.config import Config as HyperConfig from quart import Quart, g, jsonify, request -from quart import Response as QuartResponse from quart.logging import default_handler from quart_cors import cors @@ -307,25 +306,6 @@ class AstrBotDashboard: methods=["GET", "POST"], ) - self.shutdown_event = shutdown_event - - self._init_jwt_secret() - - async def srv_plug_route(self, subpath, *args, **kwargs): - """插件路由""" - registered_web_apis = self.core_lifecycle.star_context.registered_web_apis - for api in registered_web_apis: - route, view_handler, methods, _ = api - if route == f"/{subpath}" and request.method in methods: - return await view_handler(*args, **kwargs) - sdk_bridge = getattr(self.core_lifecycle, "sdk_plugin_bridge", None) - if sdk_bridge is not None: - output = await sdk_bridge.dispatch_http_request( - f"/{subpath}", request.method - ) - if output is not None: - return self._build_sdk_plugin_response(output) - return jsonify(Response().error("未找到该路由").__dict__) def _init_plugin_route_index(self): """将插件路由索引,避免 O(n) 查找""" self._plugin_route_map: dict[tuple[str, str], Callable] = {} @@ -348,37 +328,6 @@ class AstrBotDashboard: logger.info("Initialized random JWT secret for dashboard.") self._jwt_secret = dashboard_cfg["jwt_secret"] - @staticmethod - def _build_sdk_plugin_response(output: dict) -> QuartResponse: - status = int(output.get("status", 200)) - headers = output.get("headers") - if headers is None: - headers = {} - if not isinstance(headers, dict): - raise ValueError("SDK HTTP handler headers must be an object") - - body = output.get("body") - if isinstance(body, (dict, list)): - response = jsonify(body) - response.status_code = status - response.headers.setdefault("Content-Type", "application/json") - elif isinstance(body, str): - response = QuartResponse( - body, - status=status, - content_type="text/plain; charset=utf-8", - ) - elif body is None: - response = QuartResponse("", status=status) - else: - raise ValueError( - "SDK HTTP handler body must be object, array, string or null" - ) - - for key, value in headers.items(): - response.headers[str(key)] = str(value) - return response - async def auth_middleware(self): # 放行CORS预检请求 if request.method == "OPTIONS": diff --git a/pyproject.toml b/pyproject.toml index 6253d640a..e0452b52d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ requires-python = ">=3.12,<3.14" keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"] dependencies = [ - "astrbot-sdk", "aiocqhttp>=1.4.4", "aiodocker>=0.24.0", "aiohttp>=3.11.18", @@ -84,9 +83,6 @@ dev = [ "textual-dev>=1.8.0", ] -[tool.uv.sources] -astrbot-sdk = { path = "./astrbot-sdk", editable = true } - [project.scripts] astrbot = "astrbot.cli.__main__:cli" @@ -121,9 +117,8 @@ typeCheckingMode = "basic" pythonVersion = "3.10" reportMissingTypeStubs = false reportMissingImports = false -include = ["astrbot", "astrbot-sdk/src"] +include = ["astrbot"] exclude = ["dashboard", "node_modules", "dist", "data", "tests"] -extraPaths = ["astrbot-sdk/src"] [tool.hatch.metadata] allow-direct-references = true diff --git a/tests/test_computer_skill_sync.py b/tests/test_computer_skill_sync.py index a02a882f5..37715bb74 100644 --- a/tests/test_computer_skill_sync.py +++ b/tests/test_computer_skill_sync.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import zipfile from pathlib import Path from typing import cast @@ -42,31 +41,17 @@ class _FakeBooter: def __init__(self, sync_payload_json: str): self.shell = _FakeShell(sync_payload_json) self.uploads: list[tuple[str, str]] = [] - self.uploaded_entries: list[str] = [] async def upload_file(self, path: str, file_name: str) -> dict: self.uploads.append((path, file_name)) - with zipfile.ZipFile(path) as zf: - self.uploaded_entries = sorted( - name.replace("\\", "/") for name in zf.namelist() - ) return {"success": True} -def _write_sdk_registered_skill( - root: Path, - skill_name: str, -) -> None: - skill_dir = root / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8") - - -def test_sync_skills_keeps_builtin_skills_when_local_is_empty(monkeypatch, tmp_path: Path): - data_dir = tmp_path / "data" +def test_sync_skills_keeps_builtin_skills_when_local_is_empty( + monkeypatch, tmp_path: Path +): skills_root = tmp_path / "skills" temp_root = tmp_path / "temp" - data_dir.mkdir(parents=True, exist_ok=True) skills_root.mkdir(parents=True, exist_ok=True) temp_root.mkdir(parents=True, exist_ok=True) @@ -83,14 +68,6 @@ def test_sync_skills_keeps_builtin_skills_when_local_is_empty(monkeypatch, tmp_p "astrbot.core.computer.computer_client.get_astrbot_temp_path", lambda: str(temp_root), ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_root), - ) monkeypatch.setattr( "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache", _fake_set_cache, @@ -116,11 +93,9 @@ def test_sync_skills_uses_managed_strategy_instead_of_wiping_all( monkeypatch, tmp_path: Path, ): - data_dir = tmp_path / "data" skills_root = tmp_path / "skills" temp_root = tmp_path / "temp" skill_dir = skills_root / "custom-agent-skill" - data_dir.mkdir(parents=True, exist_ok=True) skill_dir.mkdir(parents=True, exist_ok=True) skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8") temp_root.mkdir(parents=True, exist_ok=True) @@ -138,14 +113,6 @@ def test_sync_skills_uses_managed_strategy_instead_of_wiping_all( "astrbot.core.computer.computer_client.get_astrbot_temp_path", lambda: str(temp_root), ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_root), - ) monkeypatch.setattr( "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache", _fake_set_cache, @@ -157,7 +124,7 @@ def test_sync_skills_uses_managed_strategy_instead_of_wiping_all( asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter))) assert len(booter.uploads) == 1 - assert booter.uploads[0][1].replace("\\", "/") == "skills/skills.zip" + assert booter.uploads[0][1] == "skills/skills.zip" assert not any( "find skills -mindepth 1 -delete" in cmd for cmd in booter.shell.commands ) @@ -170,67 +137,15 @@ def test_sync_skills_uses_managed_strategy_instead_of_wiping_all( ] -def test_sync_skills_includes_sdk_registered_skills(monkeypatch, tmp_path: Path): - data_dir = tmp_path / "data" - skills_root = tmp_path / "skills" - temp_root = tmp_path / "temp" - registered_root = tmp_path / "sdk_registered" - data_dir.mkdir(parents=True, exist_ok=True) - skills_root.mkdir(parents=True, exist_ok=True) - temp_root.mkdir(parents=True, exist_ok=True) - registered_root.mkdir(parents=True, exist_ok=True) - _write_sdk_registered_skill(registered_root, "browser-helper") +def test_build_scan_command_frontmatter_newline_is_escaped_literal(): + command = computer_client._build_scan_command() + script = _extract_embedded_python(command) - captured = {"skills": None} + assert 'frontmatter = "\\n".join(lines[1:end_idx])' in script - def _fake_set_cache(self, skills): - captured["skills"] = skills - monkeypatch.setattr( - "astrbot.core.computer.computer_client.get_astrbot_skills_path", - lambda: str(skills_root), - ) - monkeypatch.setattr( - "astrbot.core.computer.computer_client.get_astrbot_temp_path", - lambda: str(temp_root), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_root), - ) - monkeypatch.setattr( - "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache", - _fake_set_cache, - ) - SkillManager(skills_root=str(skills_root)).replace_sdk_plugin_skills( - "sdk-demo", - [ - { - "name": "sdk-demo.browser-helper", - "description": "", - "path": str(registered_root / "browser-helper" / "SKILL.md"), - "skill_dir": str(registered_root / "browser-helper"), - } - ], - ) - - booter = _FakeBooter( - '{"skills":[{"name":"sdk-demo.browser-helper","description":"","path":"skills/sdk-demo.browser-helper/SKILL.md"}]}' - ) - asyncio.run(computer_client._sync_skills_to_sandbox(booter)) - - assert len(booter.uploads) == 1 - assert "sdk-demo.browser-helper/" in booter.uploaded_entries - assert "sdk-demo.browser-helper/SKILL.md" in booter.uploaded_entries - assert captured["skills"] == [ - { - "name": "sdk-demo.browser-helper", - "description": "", - "path": "skills/sdk-demo.browser-helper/SKILL.md", - } - ] +def test_build_scan_command_embedded_python_is_syntax_valid(): + command = computer_client._build_scan_command() + script = _extract_embedded_python(command) + compile(script, "", "exec") diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 77c5fdd96..5cbfe6f90 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -520,136 +520,6 @@ async def test_commands_api(app: Quart, authenticated_header: dict): assert isinstance(data["data"], list) -@pytest.mark.asyncio -async def test_commands_api_includes_sdk_dashboard_items( - app: Quart, - authenticated_header: dict, - core_lifecycle_td: AstrBotCoreLifecycle, -): - test_client = app.test_client() - old_bridge = getattr(core_lifecycle_td, "sdk_plugin_bridge", None) - - sdk_command = { - "command_key": "sdk:command:sdk-demo:sdk-demo:main.chat", - "handler_full_name": "sdk-demo:main.chat", - "handler_name": "chat", - "plugin": "sdk-demo", - "plugin_display_name": "SDK Demo", - "module_path": "sdk-demo:main", - "description": "SDK dashboard command", - "type": "command", - "parent_signature": "", - "parent_group_handler": "", - "original_command": "chat", - "current_fragment": "chat", - "effective_command": "chat", - "aliases": [], - "permission": "everyone", - "enabled": True, - "is_group": False, - "has_conflict": False, - "reserved": False, - "runtime_kind": "sdk", - "supports_toggle": False, - "supports_rename": False, - "supports_permission": False, - "sub_commands": [], - } - - try: - core_lifecycle_td.sdk_plugin_bridge = SimpleNamespace( - list_dashboard_commands=lambda: [dict(sdk_command)] - ) - - response = await test_client.get("/api/commands", headers=authenticated_header) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - items = data["data"]["items"] - assert any( - item.get("command_key") == sdk_command["command_key"] for item in items - ) - - toggle_response = await test_client.post( - "/api/commands/toggle", - json={"command_key": sdk_command["command_key"], "enabled": False}, - headers=authenticated_header, - ) - toggle_data = await toggle_response.get_json() - assert toggle_data["status"] == "error" - assert "read-only" in toggle_data["message"] - finally: - core_lifecycle_td.sdk_plugin_bridge = old_bridge - - -@pytest.mark.asyncio -async def test_tools_api_includes_and_toggles_sdk_tools( - app: Quart, - authenticated_header: dict, - core_lifecycle_td: AstrBotCoreLifecycle, -): - test_client = app.test_client() - old_bridge = getattr(core_lifecycle_td, "sdk_plugin_bridge", None) - calls: list[tuple[str, str, str]] = [] - - sdk_tool = { - "tool_key": "sdk:sdk-demo:memory.search", - "name": "memory.search", - "description": "Search SDK memory", - "parameters": {"type": "object", "properties": {}}, - "active": True, - "origin": "sdk_plugin", - "origin_name": "SDK Demo", - "runtime_kind": "sdk", - "plugin_id": "sdk-demo", - } - - class _FakeSdkBridge: - def list_dashboard_tools(self): - return [dict(sdk_tool)] - - def get_plugin_metadata(self, _plugin_id: str): - return {"enabled": True} - - def activate_llm_tool(self, plugin_id: str, name: str) -> bool: - calls.append(("activate", plugin_id, name)) - return True - - def deactivate_llm_tool(self, plugin_id: str, name: str) -> bool: - calls.append(("deactivate", plugin_id, name)) - return True - - try: - core_lifecycle_td.sdk_plugin_bridge = _FakeSdkBridge() - - response = await test_client.get( - "/api/tools/list", headers=authenticated_header - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert any( - item.get("tool_key") == sdk_tool["tool_key"] for item in data["data"] - ) - - toggle_response = await test_client.post( - "/api/tools/toggle-tool", - json={ - "tool_key": sdk_tool["tool_key"], - "name": sdk_tool["name"], - "activate": False, - "runtime_kind": "sdk", - "plugin_id": sdk_tool["plugin_id"], - }, - headers=authenticated_header, - ) - toggle_data = await toggle_response.get_json() - assert toggle_data["status"] == "ok" - assert calls == [("deactivate", "sdk-demo", "memory.search")] - finally: - core_lifecycle_td.sdk_plugin_bridge = old_bridge - - @pytest.mark.asyncio async def test_check_update( app: Quart, @@ -1045,19 +915,15 @@ async def test_batch_upload_skills_accepts_valid_skill_archive( _fake_sync_skills_to_active_sandboxes, ) monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", + "astrbot.core.utils.astrbot_path.get_astrbot_data_path", lambda: str(data_dir), ) monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_skills_path", + "astrbot.core.utils.astrbot_path.get_astrbot_skills_path", lambda: str(skills_dir), ) monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_dir), - ) - monkeypatch.setattr( - "astrbot.dashboard.routes.skills.get_astrbot_temp_path", + "astrbot.core.utils.astrbot_path.get_astrbot_temp_path", lambda: str(temp_dir), ) diff --git a/tests/test_sdk/unit/_mcp_contract.py b/tests/test_sdk/unit/_mcp_contract.py deleted file mode 100644 index a3e148028..000000000 --- a/tests/test_sdk/unit/_mcp_contract.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - - -async def exercise_local_mcp_contract(backend) -> None: - listed = await backend.list_servers() - assert listed - first = listed[0] - assert first.name == "demo" - assert first.scope.value == "local" - - fetched = await backend.get_server("demo") - assert fetched is not None - assert fetched.name == "demo" - - disabled = await backend.disable_server("demo") - assert disabled.active is False - assert disabled.running is False - - enabled = await backend.enable_server("demo") - assert enabled.active is True - assert enabled.running is True - - ready = await backend.wait_until_ready("demo", timeout=0.2) - assert ready.running is True diff --git a/tests/test_sdk/unit/test_sdk_bridge.py b/tests/test_sdk/unit/test_sdk_bridge.py deleted file mode 100644 index 92edfe0c2..000000000 --- a/tests/test_sdk/unit/test_sdk_bridge.py +++ /dev/null @@ -1,498 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import asyncio -import importlib.util -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest -from astrbot_sdk.clients.llm import LLMResponse -from astrbot_sdk.context import CancelToken -from astrbot_sdk.decorators import ConversationMeta -from astrbot_sdk.llm.entities import ProviderRequest -from astrbot_sdk.message_components import Plain -from astrbot_sdk.message_result import MessageEventResult -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - EventTrigger, - HandlerDescriptor, - MessageTrigger, - Permissions, -) -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.loader import LoadedHandler -from astrbot_sdk.testing import MockCapabilityRouter, MockPeer - -_TRIGGER_CONVERTER_SPEC = importlib.util.spec_from_file_location( - "astrbot_sdk_bridge_trigger_converter_test", - str( - Path(__file__).resolve().parents[3] - / "astrbot" - / "core" - / "sdk_bridge" - / "trigger_converter.py" - ), -) -assert _TRIGGER_CONVERTER_SPEC is not None -assert _TRIGGER_CONVERTER_SPEC.loader is not None -_TRIGGER_CONVERTER_MODULE = importlib.util.module_from_spec(_TRIGGER_CONVERTER_SPEC) -sys.modules.setdefault( - "astrbot_sdk_bridge_trigger_converter_test", - _TRIGGER_CONVERTER_MODULE, -) -_TRIGGER_CONVERTER_SPEC.loader.exec_module(_TRIGGER_CONVERTER_MODULE) -TriggerConverter = _TRIGGER_CONVERTER_MODULE.TriggerConverter - - -class _FakeEvent: - def __init__( - self, - *, - text: str, - platform: str = "test", - message_type: str = "private", - admin: bool = False, - ) -> None: - self._text = text - self._platform = platform - self._message_type = message_type - self._admin = admin - self._group_id = "group-1" if message_type == "group" else "" - self._sender_id = "user-1" - self._has_send_oper = False - - def get_message_type(self): - return SimpleNamespace(value=self._message_type) - - def get_group_id(self) -> str: - return self._group_id - - def get_sender_id(self) -> str: - return self._sender_id - - def get_platform_name(self) -> str: - return self._platform - - def get_message_str(self) -> str: - return self._text - - def is_admin(self) -> bool: - return self._admin - - -class _CommandPlugin: - async def echo(self, phrase: str): - return {"text": phrase, "stop": True} - - -class _RegexPlugin: - async def capture(self, word: str): - return {"text": word} - - -class _ConversationPlugin: - def __init__(self) -> None: - self.started = False - - async def chat(self, event, conversation, ctx): - self.started = True - conversation.end() - - -class _LLMRequestHookPlugin: - async def decorate(self, request: ProviderRequest) -> None: - request.system_prompt = "decorated memory prompt" - request.contexts.append({"role": "system", "content": "memory: user likes tea"}) - - -class _LLMResponseHookPlugin: - async def inspect(self, response: LLMResponse) -> dict[str, object]: - return { - "text": response.text, - "llm_response": response.model_dump(exclude_none=True), - } - - -class _DecoratingResultHookPlugin: - async def decorate(self, result: MessageEventResult) -> None: - result.chain.append(Plain(" decorated", convert=False)) - - -class _SdkLocalExtrasHookPlugin: - async def persist(self, event) -> None: - assert event.get_extra("host") == "value" - assert event.get_extra("local") == "seed" - event.set_extra("local", "updated") - event.set_extra("bad", object()) - event.clear_extra() - assert event.get_extra("host") == "value" - assert event.get_extra("local", "missing") == "missing" - event.set_extra("persisted", "reply text") - - -@pytest.mark.unit -def test_trigger_converter_matches_command_and_respects_admin() -> None: - descriptor = HandlerDescriptor( - id="demo:demo.echo", - trigger=CommandTrigger(command="ping"), - priority=5, - permissions=Permissions(require_admin=True), - ) - - assert ( - TriggerConverter.match_handler( - plugin_id="demo", - descriptor=descriptor, - event=_FakeEvent(text="ping hello", admin=False), - load_order=0, - declaration_order=0, - ) - is None - ) - - match = TriggerConverter.match_handler( - plugin_id="demo", - descriptor=descriptor, - event=_FakeEvent(text="ping hello", admin=True), - load_order=0, - declaration_order=0, - ) - assert match is not None - assert match.plugin_id == "demo" - assert match.handler_id == "demo:demo.echo" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_derives_command_args_and_returns_summary() -> None: - plugin = _CommandPlugin() - router = MockCapabilityRouter() - peer = MockPeer(router) - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=peer, - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.echo", - trigger=CommandTrigger(command="ping"), - ), - callable=plugin.echo, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-1", - input={ - "handler_id": "demo:demo.echo", - "event": { - "text": "ping hello world", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - }, - }, - ), - CancelToken(), - ) - - assert result == {"sent_message": True, "stop": True, "call_llm": False} - assert router.platform_sink.records[0].text == "hello world" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_derives_regex_args() -> None: - plugin = _RegexPlugin() - router = MockCapabilityRouter() - peer = MockPeer(router) - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=peer, - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.capture", - trigger=MessageTrigger(regex=r"hello (?P\w+)"), - ), - callable=plugin.capture, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-2", - input={ - "handler_id": "demo:demo.capture", - "event": { - "text": "hello sdk", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - }, - }, - ), - CancelToken(), - ) - - assert result == {"sent_message": True, "stop": False, "call_llm": False} - assert router.platform_sink.records[0].text == "sdk" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_conversation_command_consumes_trigger_message() -> None: - plugin = _ConversationPlugin() - router = MockCapabilityRouter() - peer = MockPeer(router) - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=peer, - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.chat", - trigger=CommandTrigger(command="chat"), - ), - callable=plugin.chat, - owner=plugin, - plugin_id="demo", - conversation=ConversationMeta(timeout=60, mode="replace"), - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-3", - input={ - "handler_id": "demo:demo.chat", - "event": { - "text": "chat", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - }, - }, - ), - CancelToken(), - ) - await asyncio.sleep(0) - - assert result == {"sent_message": False, "stop": True, "call_llm": False} - assert plugin.started is True - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_injects_and_round_trips_provider_request() -> None: - plugin = _LLMRequestHookPlugin() - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=MockPeer(MockCapabilityRouter()), - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.decorate", - trigger=EventTrigger(event_type="llm_request"), - ), - callable=plugin.decorate, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-4", - input={ - "handler_id": "demo:demo.decorate", - "event": { - "type": "llm_request", - "event_type": "llm_request", - "text": "hello", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - "provider_request": { - "prompt": "hello", - "system_prompt": "original", - "contexts": [], - "image_urls": [], - "tool_calls_result": [], - }, - }, - }, - ), - CancelToken(), - ) - - assert result["sent_message"] is False - assert result["stop"] is False - assert result["call_llm"] is False - assert result["provider_request"]["system_prompt"] == "decorated memory prompt" - assert result["provider_request"]["contexts"][-1] == { - "role": "system", - "content": "memory: user likes tea", - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_injects_llm_response_payload() -> None: - plugin = _LLMResponseHookPlugin() - router = MockCapabilityRouter() - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=MockPeer(router), - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.inspect", - trigger=EventTrigger(event_type="llm_response"), - ), - callable=plugin.inspect, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-5", - input={ - "handler_id": "demo:demo.inspect", - "event": { - "type": "llm_response", - "event_type": "llm_response", - "text": "hello", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - "llm_response": { - "text": "reply text", - "usage": {"total_tokens": 3}, - "finish_reason": "stop", - "tool_calls": [], - "role": "assistant", - }, - }, - }, - ), - CancelToken(), - ) - - assert result["sent_message"] is True - assert router.platform_sink.records[0].text == "reply text" - assert result["llm_response"]["text"] == "reply text" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_injects_and_round_trips_event_result() -> None: - plugin = _DecoratingResultHookPlugin() - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=MockPeer(MockCapabilityRouter()), - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.decorate_result", - trigger=EventTrigger(event_type="decorating_result"), - ), - callable=plugin.decorate, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-6", - input={ - "handler_id": "demo:demo.decorate_result", - "event": { - "type": "decorating_result", - "event_type": "decorating_result", - "text": "hello", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - "event_result": { - "type": "chain", - "chain": [{"type": "plain", "data": {"text": "base"}}], - }, - }, - }, - ), - CancelToken(), - ) - - assert result["event_result"]["chain"][0]["data"]["text"] == "base" - assert result["event_result"]["chain"][1]["data"]["text"] == " decorated" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_round_trips_sdk_local_extras() -> None: - plugin = _SdkLocalExtrasHookPlugin() - dispatcher = HandlerDispatcher( - plugin_id="demo", - peer=MockPeer(MockCapabilityRouter()), - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:demo.persist", - trigger=EventTrigger(event_type="after_message_sent"), - ), - callable=plugin.persist, - owner=plugin, - plugin_id="demo", - ) - ], - ) - - result = await dispatcher.invoke( - SimpleNamespace( - id="req-7", - input={ - "handler_id": "demo:demo.persist", - "event": { - "type": "after_message_sent", - "event_type": "after_message_sent", - "text": "hello", - "session_id": "test-session", - "user_id": "test-user", - "platform": "test", - "message_type": "private", - "extras": {"host": "value", "local": "seed"}, - "host_extras": {"host": "value"}, - "sdk_local_extras": {"local": "seed"}, - }, - }, - ), - CancelToken(), - ) - - assert result["sent_message"] is False - assert result["stop"] is False - assert result["call_llm"] is False - assert result["sdk_local_extras"] == {"persisted": "reply text"} diff --git a/tests/test_sdk/unit/test_sdk_bridge_runtime_capabilities.py b/tests/test_sdk/unit/test_sdk_bridge_runtime_capabilities.py deleted file mode 100644 index 3657f31b4..000000000 --- a/tests/test_sdk/unit/test_sdk_bridge_runtime_capabilities.py +++ /dev/null @@ -1,1331 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -from asyncio import Queue -import sys -import types -from functools import partial -from pathlib import Path - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - install( - "aiocqhttp", - { - "CQHttp": type("CQHttp", (), {}), - "Event": type("Event", (), {}), - }, - ) - install( - "aiocqhttp.exceptions", - {"ActionFailed": type("ActionFailed", (Exception,), {})}, - ) - - -_install_optional_dependency_stubs() - -from astrbot_sdk import MessageSession -from astrbot_sdk.clients.registry import HandlerMetadata -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - EventTrigger, - HandlerDescriptor, - ParamSpec, - ScheduleTrigger, -) -from astrbot_sdk.testing import MockContext - -from astrbot.core.message.components import Plain -from astrbot.core.message.message_event_result import ( - MessageChain, - MessageEventResult, - ResultContentType, -) -from astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party import ( - ThirdPartyAgentSubStage, -) -from astrbot.core.pipeline.respond.stage import RespondStage -from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage -from astrbot.core.provider.entities import ProviderRequest as CoreProviderRequest -from astrbot.core.sdk_bridge.event_converter import EventConverter -from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge -from astrbot.core.star.context import Context as StarContext - - -@pytest.mark.unit -def test_message_event_extensions_and_local_stop_control() -> None: - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "test-platform:private:user-1", - "platform": "test-platform", - "platform_id": "test-platform-id", - "message_type": "private", - "self_id": "bot-1", - "sender_name": "Tester", - "is_admin": True, - } - ) - - assert event.unified_msg_origin == "test-platform:private:user-1" - assert event.get_session_id() == "test-platform:private:user-1" - assert event.get_platform_id() == "test-platform-id" - assert event.get_message_type() == "private" - assert event.is_private_chat() is True - assert event.is_admin() is True - - event.stop_event() - assert event.is_stopped() is True - event.continue_event() - assert event.is_stopped() is False - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_system_tools_and_memory_stats() -> None: - ctx = MockContext(plugin_id="sdk-demo") - - data_dir = await ctx.get_data_dir() - assert isinstance(data_dir, Path) - assert data_dir.name == "sdk-demo" - - image_result = await ctx.text_to_image("hello sdk") - assert image_result == "mock://text_to_image/hello sdk" - - html_result = await ctx.html_render("card.html", {"title": "AstrBot"}) - assert html_result == "mock://html_render/card.html" - - await ctx.memory.save("profile", {"name": "AstrBot"}) - await ctx.memory.save_with_ttl("temp", {"value": "cached"}, 60) - stats = await ctx.memory.stats() - - assert stats["total_items"] == 2 - assert stats["plugin_id"] == "sdk-demo" - assert stats["ttl_entries"] == 1 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_metadata_save_plugin_config_round_trip() -> None: - ctx = MockContext(plugin_id="sdk-demo") - - saved = await ctx.metadata.save_plugin_config({"chat_scope_mode": "global_default"}) - - assert saved == {"chat_scope_mode": "global_default"} - assert await ctx.metadata.get_plugin_config() == { - "chat_scope_mode": "global_default" - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_platform_client_accepts_message_session() -> None: - ctx = MockContext(plugin_id="sdk-demo") - session = MessageSession( - platform_id="test-platform", - message_type="private", - session_id="user-42", - ) - - await ctx.platform.send(session, "hello session") - - assert len(ctx.sent_messages) == 1 - assert ctx.sent_messages[0].session_id == "test-platform:private:user-42" - assert ctx.sent_messages[0].text == "hello session" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_platform_and_session_managers() -> None: - ctx = MockContext(plugin_id="sdk-demo") - session = "test-platform:group:room-7" - ctx.router.set_session_plugin_config( - session, - disabled_plugins=["sdk-disabled"], - ) - ctx.router.set_session_service_config( - session, - llm_enabled=False, - tts_enabled=False, - ) - ctx.router.upsert_plugin( - metadata={ - "name": "sdk-disabled", - "display_name": "sdk-disabled", - "reserved": False, - }, - config={}, - ) - ctx.router.upsert_plugin( - metadata={ - "name": "sdk-reserved", - "display_name": "sdk-reserved", - "reserved": True, - }, - config={}, - ) - - await ctx.platform.send_by_session(session, "hello proactive") - group = await MessageEvent.from_payload( - { - "text": "hello", - "session_id": session, - "platform": "test-platform", - "platform_id": "test-platform", - "message_type": "group", - }, - context=ctx, - ).get_group() - members = await ctx.platform.get_members(session) - handlers = await ctx.session_plugins.filter_handlers_by_session( - session, - [ - HandlerMetadata( - plugin_name="sdk-disabled", - handler_full_name="sdk-disabled:main.on_message", - trigger_type="message", - description="disabled handler", - event_types=[], - enabled=True, - group_path=[], - priority=1, - kind="handler", - require_admin=False, - ), - HandlerMetadata( - plugin_name="sdk-reserved", - handler_full_name="sdk-reserved:main.on_message", - trigger_type="message", - description="reserved handler", - event_types=[], - enabled=True, - group_path=[], - priority=5, - kind="hook", - require_admin=True, - ), - ], - ) - - assert ctx.sent_messages[-1].session_id == session - assert ctx.sent_messages[-1].chain == [ - {"type": "text", "data": {"text": "hello proactive"}} - ] - assert group is not None - assert group["group_id"] == "room-7" - assert len(members) == 2 - assert ( - await ctx.session_plugins.is_plugin_enabled_for_session(session, "sdk-disabled") - is False - ) - assert [item.plugin_name for item in handlers] == ["sdk-reserved"] - assert handlers[0].description == "reserved handler" - assert handlers[0].priority == 5 - assert handlers[0].kind == "hook" - assert handlers[0].require_admin is True - assert await ctx.session_services.is_llm_enabled_for_session(session) is False - assert await ctx.session_services.should_process_llm_request(session) is False - await ctx.session_services.set_llm_status_for_session(session, True) - assert await ctx.session_services.is_llm_enabled_for_session(session) is True - assert await ctx.session_services.is_tts_enabled_for_session(session) is False - assert await ctx.session_services.should_process_tts_request(session) is False - await ctx.session_services.set_tts_status_for_session(session, True) - assert await ctx.session_services.is_tts_enabled_for_session(session) is True - - current = await ctx.conversations.get_current_conversation( - session, - create_if_not_exists=True, - ) - assert current is not None - assert current.session == session - - -@pytest.mark.unit -def test_message_session_round_trip() -> None: - session = MessageSession.from_str("demo-platform:group:room-7") - - assert session.platform_id == "demo-platform" - assert session.message_type == "group" - assert session.session_id == "room-7" - assert str(session) == "demo-platform:group:room-7" - - -class _EventConverterProbe: - def __init__(self) -> None: - self.is_wake = False - self.is_at_or_wake_command = False - self.unified_msg_origin = "demo-platform:private:user-1" - self._extras = { - "serializable": {"value": 1}, - "callback": partial(str.upper, "demo"), - } - - def get_message_type(self): - return types.SimpleNamespace(value="private") - - def get_platform_id(self) -> str: - return "demo-platform-id" - - def get_message_str(self) -> str: - return "demo text" - - def get_sender_id(self) -> str: - return "user-1" - - def get_group_id(self) -> str | None: - return None - - def get_platform_name(self) -> str: - return "demo-platform" - - def get_self_id(self) -> str: - return "bot-1" - - def get_sender_name(self) -> str: - return "Tester" - - def is_admin(self) -> bool: - return False - - def get_message_outline(self) -> str: - return "demo outline" - - def get_extra(self, key: str | None = None, default=None): - if key is None: - return self._extras - return self._extras.get(key, default) - - def get_messages(self): - return [Plain("demo", convert=False)] - - -@pytest.mark.unit -def test_event_converter_sanitizes_non_serializable_extras() -> None: - payload = EventConverter.core_to_sdk( - _EventConverterProbe(), - dispatch_token="dispatch-1", - plugin_id="sdk-demo", - request_id="req-1", - ) - - assert payload["extras"] == {"serializable": {"value": 1}} - assert "callback" not in payload["extras"] - - -@pytest.mark.unit -def test_respond_stage_sdk_outline_supports_list_and_message_chain() -> None: - chain_list = [Plain("hello", convert=False), Plain(" world", convert=False)] - - assert RespondStage._message_outline_for_sdk_event(chain_list) == "hello world" - assert ( - RespondStage._message_outline_for_sdk_event(MessageChain(chain_list)) - == "hello world" - ) - assert RespondStage._message_outline_for_sdk_event(None) == "" - - -@pytest.mark.unit -def test_result_decorate_stage_sdk_outline_supports_list_and_message_chain() -> None: - chain_list = [Plain("hello", convert=False), Plain(" world", convert=False)] - - assert ( - ResultDecorateStage._message_outline_for_sdk_event(chain_list) == "hello world" - ) - assert ( - ResultDecorateStage._message_outline_for_sdk_event(MessageChain(chain_list)) - == "hello world" - ) - assert ResultDecorateStage._message_outline_for_sdk_event(None) == "" - - -class _OverlayFakeStarContext: - def __init__(self) -> None: - self.registered_web_apis = [] - self.cron_manager = object() - - def get_all_stars(self) -> list[object]: - return [] - - -class _ScheduleDispatchStarContext(_OverlayFakeStarContext): - def __init__(self) -> None: - super().__init__() - self.sent_messages: list[tuple[str, MessageChain]] = [] - - async def send_message(self, session: str, message_chain: MessageChain) -> None: - self.sent_messages.append((session, message_chain)) - - -class _OverlayFakeEvent: - def __init__(self) -> None: - self.call_llm = False - self._result = MessageEventResult(chain=[Plain("legacy", convert=False)]) - self._sdk_dispatch_token = "dispatch-1" - - def get_result(self) -> MessageEventResult | None: - return self._result - - -class _TypedHookFakeEvent: - def __init__(self) -> None: - self.call_llm = False - self.is_wake = False - self.is_at_or_wake_command = False - self.unified_msg_origin = "demo-platform:private:user-1" - self._sdk_dispatch_token = "dispatch-typed" - self._result = MessageEventResult(chain=[Plain("legacy", convert=False)]) - self._extras: dict[str, object] = {} - - def get_message_type(self): - return types.SimpleNamespace(value="private") - - def get_platform_id(self) -> str: - return "demo-platform" - - def get_message_str(self) -> str: - return "hello" - - def get_sender_id(self) -> str: - return "user-1" - - def get_group_id(self) -> str | None: - return None - - def get_platform_name(self) -> str: - return "demo-platform" - - def get_self_id(self) -> str: - return "bot-1" - - def get_sender_name(self) -> str: - return "Tester" - - def is_admin(self) -> bool: - return False - - def get_message_outline(self) -> str: - return "hello" - - def get_extra(self, key: str | None = None, default=None): - if key is None: - return self._extras - return self._extras.get(key, default) - - def get_messages(self): - return [Plain("hello", convert=False)] - - def get_result(self) -> MessageEventResult | None: - return self._result - - -class _TypedHookSession: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, object]]] = [] - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, object], - *, - request_id: str, - args: dict[str, object], - ) -> dict[str, object]: - del request_id, args - self.calls.append((handler_id, event_payload)) - return { - "provider_request": { - **dict(event_payload["provider_request"]), - "system_prompt": "decorated memory prompt", - "contexts": [ - {"role": "system", "content": "memory: user likes tea"}, - ], - }, - "event_result": { - "type": "chain", - "chain": [{"type": "text", "data": {"text": "decorated result"}}], - }, - } - - -class _RequestScopedHookSession: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, object]]] = [] - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, object], - *, - request_id: str, - args: dict[str, object], - ) -> dict[str, object]: - del request_id, args - self.calls.append((handler_id, event_payload)) - if handler_id.endswith("capture_reply"): - return {"sdk_local_extras": {"last_reply": "reply text"}} - return {"sdk_local_extras": {}} - - -class _SystemEventSession: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, object]]] = [] - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, object], - *, - request_id: str, - args: dict[str, object], - ) -> dict[str, object]: - del request_id, args - self.calls.append((handler_id, event_payload)) - return {} - - -class _RequestScopeSession: - def __init__(self) -> None: - self.request_ids: list[str] = [] - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, object], - *, - request_id: str, - args: dict[str, object], - ) -> dict[str, object]: - del handler_id, event_payload, args - self.request_ids.append(request_id) - return {} - - -class _ScheduleDispatchSession: - def __init__(self, bridge: SdkPluginBridge) -> None: - self.bridge = bridge - self.request_ids: list[str] = [] - self.event_capability_results: list[dict[str, object]] = [] - - async def invoke_handler( - self, - handler_id: str, - event_payload: dict[str, object], - *, - request_id: str, - args: dict[str, object], - ) -> dict[str, object]: - del handler_id, event_payload, args - self.request_ids.append(request_id) - request_context = self.bridge.resolve_request_session(request_id) - assert request_context is not None - assert request_context.has_event is False - send_result = await self.bridge.capability_bridge.execute( - "platform.send", - { - "session": "demo-platform:private:user-1", - "text": "scheduled hello", - }, - stream=False, - cancel_token=None, - request_id=request_id, - ) - assert str(send_result["message_id"]).startswith("sdk_") - event_result = await self.bridge.capability_bridge.execute( - "system.event.send_typing", - {}, - stream=False, - cancel_token=None, - request_id=request_id, - ) - self.event_capability_results.append(event_result) - return {} - - -class _CaptureSystemBridge: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, object]]] = [] - - async def dispatch_system_event( - self, - event_type: str, - payload: dict[str, object] | None = None, - ) -> None: - self.calls.append((event_type, dict(payload or {}))) - - -class _FakePlatform: - def __init__(self) -> None: - self.sent: list[tuple[object, MessageChain]] = [] - - class _Meta: - id = "demo" - name = "Demo Platform" - - def meta(self): - return self._Meta() - - async def send_by_session(self, session, message_chain: MessageChain) -> None: - self.sent.append((session, message_chain)) - - -class _ThirdPartyDispatchBridge: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, object], object | None]] = [] - - async def dispatch_message_event( - self, - event_type: str, - event, - payload: dict[str, object] | None = None, - *, - provider_request=None, - **_: object, - ) -> None: - del event - self.calls.append((event_type, dict(payload or {}), provider_request)) - - -class _ThirdPartyFakeEvent: - def __init__(self) -> None: - self.message_str = "hello runner" - self.unified_msg_origin = "demo:private:user-1" - self.message_obj = types.SimpleNamespace(message=[]) - self.extra: dict[str, object] = {} - - def set_extra(self, key: str, value: object) -> None: - self.extra[key] = value - - -class _DecoratingResultFakeBridge: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, str]]] = [] - - def get_effective_result( - self, event: _DecoratingResultFakeEvent - ) -> MessageEventResult | None: - return event.get_result() - - async def dispatch_message_event( - self, - event_type: str, - event: _DecoratingResultFakeEvent, - payload: dict[str, str], - **_: object, - ) -> None: - self.calls.append((event_type, payload)) - - -class _DecoratingResultFakeEvent: - def __init__(self) -> None: - self.plugins_name: list[str] = [] - self._stopped = False - self._result = MessageEventResult( - chain=[Plain("legacy", convert=False)], - result_content_type=ResultContentType.STREAMING_FINISH, - ) - - def get_result(self) -> MessageEventResult | None: - return self._result - - def is_stopped(self) -> bool: - return self._stopped - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_result_decorate_stage_dispatches_sdk_outline_for_legacy_chain_list() -> ( - None -): - stage = ResultDecorateStage() - bridge = _DecoratingResultFakeBridge() - event = _DecoratingResultFakeEvent() - - stage.sdk_plugin_bridge = bridge - stage.content_safe_check_reply = False - stage.content_safe_check_stage = None - - async for _ in stage.process(event): - pass - - assert bridge.calls == [ - ("decorating_result", {"message_outline": "legacy"}), - ] - - -@pytest.mark.unit -def test_sdk_request_overlay_controls_llm_result_and_whitelist() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - event = _OverlayFakeEvent() - request_id = "req-1" - - bridge._request_id_to_token[request_id] = "dispatch-1" - bridge._request_overlays["dispatch-1"] = bridge._ensure_request_overlay( - "dispatch-1", - should_call_llm=False, - ) - - assert bridge.get_effective_should_call_llm(event) is False - assert bridge.request_llm_for_request(request_id) is True - assert bridge.get_effective_should_call_llm(event) is True - - payload = { - "type": "chain", - "chain": [{"type": "plain", "data": {"text": "overlay"}}], - } - assert bridge.set_result_for_request(request_id, payload) is True - effective_result = bridge.get_effective_result(event) - assert effective_result is not None - assert effective_result.chain.get_plain_text() == "overlay" - - effective_result.chain.chain.append(Plain("cached", convert=False)) - result_payload = bridge.get_result_payload_for_request(request_id) - assert result_payload is not None - assert result_payload["chain"][1]["data"]["text"] == "cached" - - assert ( - bridge.set_handler_whitelist_for_request(request_id, {"sdk-a", "sdk-b"}) is True - ) - assert bridge.get_handler_whitelist_for_request(request_id) == { - "sdk-a", - "sdk-b", - } - - assert bridge.clear_result_for_request(request_id) is True - assert bridge.get_effective_result(event) is None - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_keeps_request_scope_after_event_hook_returns() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - session = _RequestScopeSession() - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - handlers=[ - types.SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.observe", - trigger=EventTrigger(event_type="after_message_sent"), - ), - declaration_order=0, - ) - ], - session=session, - ) - } - - event = _TypedHookFakeEvent() - bridge._request_overlays["dispatch-typed"] = bridge._ensure_request_overlay( - "dispatch-typed", - should_call_llm=False, - ) - - await bridge.dispatch_message_event( - "after_message_sent", - event, - {"message_outline": "reply text"}, - ) - - parent_request_id = session.request_ids[0] - - llm_state = await bridge.capability_bridge.execute( - "system.event.llm.request", - {"_request_scope_id": parent_request_id}, - stream=False, - cancel_token=None, - request_id="child-llm-request", - ) - assert llm_state == {"should_call_llm": True, "requested_llm": True} - - result_payload = { - "type": "chain", - "chain": [{"type": "text", "data": {"text": "reply text"}}], - } - set_result = await bridge.capability_bridge.execute( - "system.event.result.set", - { - "_request_scope_id": parent_request_id, - "result": result_payload, - }, - stream=False, - cancel_token=None, - request_id="child-result-set", - ) - assert set_result == {"result": result_payload} - - whitelist = await bridge.capability_bridge.execute( - "system.event.handler_whitelist.set", - { - "_request_scope_id": parent_request_id, - "plugin_names": ["sdk-demo"], - }, - stream=False, - cancel_token=None, - request_id="child-whitelist-set", - ) - assert whitelist == {"plugin_names": ["sdk-demo"]} - - bridge.close_request_overlay_for_event(event) - assert bridge.get_request_overlay_by_request_id(parent_request_id) is None - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_schedule_handler_tracks_request_scope_for_proactive_send() -> None: - star_context = _ScheduleDispatchStarContext() - bridge = SdkPluginBridge(star_context) - session = _ScheduleDispatchSession(bridge) - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - session=session, - ) - } - - await bridge._invoke_schedule_handler( - plugin_id="sdk-demo", - handler_id="sdk-demo:main.tick", - trigger=ScheduleTrigger(interval_seconds=60), - ) - - assert len(star_context.sent_messages) == 1 - assert star_context.sent_messages[0][0] == "demo-platform:private:user-1" - assert star_context.sent_messages[0][1].get_plain_text() == "scheduled hello" - assert session.event_capability_results == [{"supported": False}] - request_context = bridge.resolve_request_session(session.request_ids[0]) - assert request_context is not None - assert request_context.has_event is False - bridge._close_request_overlay(request_context.dispatch_token) - assert bridge.resolve_request_session(session.request_ids[0]) is None - - -@pytest.mark.unit -def test_unregister_http_api_empty_methods_remove_entire_route() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - bridge.register_http_api( - plugin_id="sdk-demo", - route="/health", - methods=["GET", "POST"], - handler_capability="http.health", - description="health endpoint", - ) - - bridge.unregister_http_api( - plugin_id="sdk-demo", - route="/health", - methods=["POST"], - ) - assert bridge.list_http_apis("sdk-demo") == [ - { - "route": "/health", - "methods": ["GET"], - "handler_capability": "http.health", - "description": "health endpoint", - } - ] - - bridge.unregister_http_api( - plugin_id="sdk-demo", - route="/health", - methods=[], - ) - assert bridge.list_http_apis("sdk-demo") == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_bridge_dispatch_message_event_round_trips_typed_payloads() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - session = _TypedHookSession() - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - handlers=[ - types.SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.on_llm_request", - trigger=EventTrigger(event_type="llm_request"), - ), - declaration_order=0, - ) - ], - session=session, - ) - } - - event = _TypedHookFakeEvent() - bridge._request_overlays["dispatch-typed"] = bridge._ensure_request_overlay( - "dispatch-typed", - should_call_llm=True, - ) - request = CoreProviderRequest( - prompt="hello", - session_id=event.unified_msg_origin, - contexts=[], - system_prompt="original", - ) - result = event.get_result() - assert result is not None - - await bridge.dispatch_message_event( - "llm_request", - event, - {"prompt": request.prompt, "provider_id": "demo-provider"}, - provider_request=request, - event_result=result, - ) - - assert len(session.calls) == 1 - sent_payload = session.calls[0][1] - assert sent_payload["provider_request"]["system_prompt"] == "original" - assert request.system_prompt == "decorated memory prompt" - assert request.contexts == [{"role": "system", "content": "memory: user likes tea"}] - - effective_result = bridge.get_effective_result(event) - assert effective_result is not None - assert effective_result.chain.get_plain_text() == "decorated result" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_bridge_persists_request_scoped_extras_and_sent_payloads() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - session = _RequestScopedHookSession() - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - handlers=[ - types.SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.capture_reply", - trigger=EventTrigger(event_type="llm_response"), - ), - declaration_order=0, - ), - types.SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.persist_reply", - trigger=EventTrigger(event_type="after_message_sent"), - ), - declaration_order=1, - ), - ], - session=session, - ) - } - - event = _TypedHookFakeEvent() - event._extras = {"host": "value"} - bridge._request_overlays["dispatch-typed"] = bridge._ensure_request_overlay( - "dispatch-typed", - should_call_llm=True, - ) - - await bridge.dispatch_message_event( - "llm_response", event, {"completion_text": "reply text"} - ) - await bridge.dispatch_message_event( - "after_message_sent", - event, - { - "message_outline": "reply text", - "sent_message_outline": "reply text", - "sent_messages": [ - {"type": "text", "data": {"text": "reply text"}}, - ], - }, - ) - - assert len(session.calls) == 2 - first_payload = session.calls[0][1] - second_payload = session.calls[1][1] - assert first_payload["sdk_local_extras"] == {} - assert second_payload["extras"] == {"host": "value", "last_reply": "reply text"} - assert second_payload["sdk_local_extras"] == {"last_reply": "reply text"} - assert second_payload["text"] == "hello" - assert second_payload["message_outline"] == "reply text" - assert second_payload["sent_message_outline"] == "reply text" - assert second_payload["sent_messages"] == [ - {"type": "text", "data": {"text": "reply text"}} - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_bridge_dispatch_system_event_exposes_sent_payload_fields() -> None: - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - session = _SystemEventSession() - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - handlers=[ - types.SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.after_send", - trigger=EventTrigger(event_type="after_message_sent"), - ), - declaration_order=0, - ) - ], - session=session, - ) - } - - await bridge.dispatch_system_event( - "after_message_sent", - { - "session_id": "demo:private:user-1", - "platform": "Demo Platform", - "platform_id": "demo", - "message_type": "private", - "message_outline": "reply text", - "sent_message_outline": "reply text", - "sent_messages": [ - {"type": "text", "data": {"text": "reply text"}}, - ], - }, - ) - - sent_payload = session.calls[0][1] - assert sent_payload["text"] == "reply text" - assert sent_payload["message_outline"] == "reply text" - assert sent_payload["sent_message_outline"] == "reply text" - assert sent_payload["sent_messages"] == [ - {"type": "text", "data": {"text": "reply text"}} - ] - - -@pytest.mark.unit -def test_sdk_bridge_dynamic_command_routes_register_and_match() -> None: - class _RouteFakeEvent: - def __init__(self, text: str) -> None: - self._text = text - - def get_message_type(self): - return types.SimpleNamespace(value="private") - - def get_group_id(self) -> str: - return "" - - def get_sender_id(self) -> str: - return "user-1" - - def get_platform_name(self) -> str: - return "test-platform" - - def get_message_str(self) -> str: - return self._text - - def is_admin(self) -> bool: - return False - - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - descriptor = HandlerDescriptor( - id="sdk-demo:demo.echo", - trigger=CommandTrigger(command="noop"), - param_specs=[ParamSpec(name="phrase", type="greedy_str")], - ) - handler_ref = types.SimpleNamespace(descriptor=descriptor, declaration_order=0) - bridge._records = { - "sdk-demo": types.SimpleNamespace( - state="enabled", - plugin_id="sdk-demo", - load_order=0, - handlers=[handler_ref], - dynamic_command_routes=[], - session=object(), - ) - } - - bridge.register_dynamic_command_route( - plugin_id="sdk-demo", - command_name="hello", - handler_full_name="sdk-demo:demo.echo", - desc="dynamic hello", - priority=6, - ) - matches = bridge._match_handlers(_RouteFakeEvent("hello world")) - - assert len(matches) == 1 - assert matches[0].handler_id == "sdk-demo:demo.echo" - assert matches[0].args == {"phrase": "world"} - - -@pytest.mark.unit -def test_sdk_bridge_register_skill_requires_plugin_local_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - plugin_root = tmp_path / "sdk_demo" - skill_dir = plugin_root / "skills" / "browser-helper" - skill_dir.mkdir(parents=True, exist_ok=True) - skill_dir.joinpath("SKILL.md").write_text( - "---\ndescription: demo skill\n---\n# skill\n", - encoding="utf-8", - ) - - bridge = SdkPluginBridge(_OverlayFakeStarContext()) - published: list[str] = [] - monkeypatch.setattr( - bridge, - "_publish_plugin_skills", - lambda plugin_id: published.append(plugin_id), - ) - bridge._records = { - "sdk-demo": types.SimpleNamespace( - plugin=types.SimpleNamespace(plugin_dir=plugin_root), - skills={}, - ) - } - - registered = bridge.register_skill( - plugin_id="sdk-demo", - name="sdk-demo.browser-helper", - path="skills/browser-helper", - description="", - ) - - assert registered["name"] == "sdk-demo.browser-helper" - assert registered["description"] == "demo skill" - assert published == ["sdk-demo"] - - outside_path = tmp_path / "outside" / "SKILL.md" - outside_path.parent.mkdir(parents=True, exist_ok=True) - outside_path.write_text("# nope", encoding="utf-8") - with pytest.raises(Exception, match="must stay inside the plugin directory"): - bridge.register_skill( - plugin_id="sdk-demo", - name="sdk-demo.outside", - path=str(outside_path), - description="", - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_context_send_message_populates_proactive_sent_fields() -> None: - platform = _FakePlatform() - ctx = StarContext( - event_queue=Queue(), - config={}, - db=object(), - provider_manager=object(), - platform_manager=types.SimpleNamespace(platform_insts=[platform]), - conversation_manager=object(), - message_history_manager=object(), - persona_manager=object(), - astrbot_config_mgr=object(), - knowledge_base_manager=object(), - cron_manager=object(), - ) - bridge = _CaptureSystemBridge() - ctx.sdk_plugin_bridge = bridge - - sent = await ctx.send_message( - "demo:FriendMessage:user-1", - MessageChain([Plain("hello proactive", convert=False)]), - ) - - assert sent is True - assert len(platform.sent) == 1 - assert bridge.calls == [ - ( - "after_message_sent", - { - "session_id": "demo:FriendMessage:user-1", - "platform": "Demo Platform", - "platform_id": "demo", - "message_type": "FriendMessage", - "message_outline": "hello proactive", - "sent_message_outline": "hello proactive", - "sent_messages": [ - {"type": "text", "data": {"text": "hello proactive"}} - ], - }, - ) - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_third_party_runner_dispatches_live_provider_request_to_sdk_hooks( - monkeypatch: pytest.MonkeyPatch, -) -> None: - module = sys.modules[ - "astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party" - ] - monkeypatch.setattr(module, "astrbot_config", {"provider": [{"id": "provider-1"}]}) - - async def fake_call_event_hook(*_args, **_kwargs) -> bool: - return False - - async def fake_resolve_persona_message(_event) -> None: - return None - - monkeypatch.setattr(module, "call_event_hook", fake_call_event_hook) - monkeypatch.setattr( - module, - "set_persona_custom_error_message_on_event", - lambda *_args, **_kwargs: None, - ) - - bridge = _ThirdPartyDispatchBridge() - stage = ThirdPartyAgentSubStage() - stage.ctx = types.SimpleNamespace( - plugin_manager=types.SimpleNamespace( - context=types.SimpleNamespace( - sdk_plugin_bridge=bridge, - conversation_manager=object(), - persona_manager=object(), - ) - ) - ) - stage.conf = { - "provider_settings": { - "agent_runner_type": "unsupported", - "unsupported_streaming_strategy": "turn_off", - "streaming_response": False, - } - } - stage.runner_type = "unsupported" - stage.prov_id = "provider-1" - stage.streaming_response = False - stage.unsupported_streaming_strategy = "turn_off" - stage.stream_consumption_close_timeout_sec = 30 - stage._resolve_persona_custom_error_message = fake_resolve_persona_message - event = _ThirdPartyFakeEvent() - - with pytest.raises(ValueError, match="Unsupported third party agent runner type"): - async for _ in stage.process(event, ""): - pass - - assert len(bridge.calls) == 1 - event_type, payload, provider_request = bridge.calls[0] - assert event_type == "llm_request" - assert payload == {"prompt": "hello runner", "provider_id": "provider-1"} - assert provider_request is not None - assert provider_request.prompt == "hello runner" - assert provider_request.session_id == "demo:private:user-1" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_registry_client_round_trip() -> None: - ctx = MockContext(plugin_id="sdk-demo") - ctx.router.set_plugin_handlers( - "sdk-demo", - [ - { - "plugin_name": "sdk-demo", - "handler_full_name": "sdk-demo:demo.on_waiting", - "trigger_type": "event", - "description": "Observe waiting requests", - "event_types": ["waiting_llm_request"], - "enabled": True, - "group_path": [], - "priority": 7, - "kind": "hook", - "require_admin": True, - } - ], - ) - - handlers = await ctx.registry.get_handlers_by_event_type("waiting_llm_request") - assert len(handlers) == 1 - assert handlers[0].handler_full_name == "sdk-demo:demo.on_waiting" - assert handlers[0].description == "Observe waiting requests" - assert handlers[0].priority == 7 - assert handlers[0].kind == "hook" - assert handlers[0].require_admin is True - - handler = await ctx.registry.get_handler_by_full_name("sdk-demo:demo.on_waiting") - assert handler is not None - assert handler.plugin_name == "sdk-demo" - assert handler.description == "Observe waiting requests" - assert handler.priority == 7 - assert handler.kind == "hook" - assert handler.require_admin is True - - request_id = "req-registry-whitelist" - set_result = await ctx.router.execute( - "system.event.handler_whitelist.set", - {"plugin_names": ["sdk-demo"]}, - stream=False, - cancel_token=None, - request_id=request_id, - ) - assert set_result == {"plugin_names": ["sdk-demo"]} - get_result = await ctx.router.execute( - "system.event.handler_whitelist.get", - {}, - stream=False, - cancel_token=None, - request_id=request_id, - ) - assert get_result == {"plugin_names": ["sdk-demo"]} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_skill_client_round_trip() -> None: - ctx = MockContext(plugin_id="sdk-demo") - - registered = await ctx.skills.register( - name="sdk-demo.browser-helper", - path="/tmp/sdk-demo/browser-helper/SKILL.md", - description="demo skill", - ) - assert registered.name == "sdk-demo.browser-helper" - assert registered.description == "demo skill" - assert registered.skill_dir.replace("\\", "/") == "/tmp/sdk-demo/browser-helper" - - listed = await ctx.skills.list() - assert len(listed) == 1 - assert listed[0].name == "sdk-demo.browser-helper" - assert listed[0].path.replace("\\", "/") == "/tmp/sdk-demo/browser-helper/SKILL.md" - - removed = await ctx.skills.unregister("sdk-demo.browser-helper") - assert removed is True - assert await ctx.skills.list() == [] diff --git a/tests/test_sdk/unit/test_sdk_core_bridge_memory_capabilities.py b/tests/test_sdk/unit/test_sdk_core_bridge_memory_capabilities.py deleted file mode 100644 index 35b68bfce..000000000 --- a/tests/test_sdk/unit/test_sdk_core_bridge_memory_capabilities.py +++ /dev/null @@ -1,481 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import math -import sys -import types -from datetime import datetime, timedelta, timezone -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - class _FakeArray: - def __init__(self, data): - self.data = data if isinstance(data, list) else [] - - def reshape(self, *args): - return _FakeArray(self.data) - - def __len__(self): - return len(self.data) - - def __iter__(self): - return iter(self.data) - - def __getitem__(self, key): - return self.data[key] - - class _FakeNumpyArray(_FakeArray): - pass - - def _fake_numpy_array(data, dtype=None): - rows = data if isinstance(data, list) else [data] - if dtype == "float32": - normalized = [ - [float(x) for x in row] if isinstance(row, list) else [float(row)] - for row in rows - ] - return _FakeNumpyArray(normalized) - return _FakeNumpyArray(rows) - - class _FakeIndex: - def __init__(self, *args, **kwargs): - self.ntotal = 0 - self._vectors = [] - self._ids = [] - - def add_with_ids(self, vectors, ids): - self._vectors = list(vectors) if hasattr(vectors, "__iter__") else [] - self._ids = list(ids) if hasattr(ids, "__iter__") else [] - self.ntotal = len(self._ids) - - def search(self, query, k): - # Simulate vector search by returning all stored IDs - import numpy as np - - if self.ntotal == 0: - return np.array([]).reshape(0, 1), np.array([-1]).reshape(0, 1) - scores = [[1.0] * k for _ in range(1)] - ids = [[i for i in self._ids[:k]]] - return np.array(scores), np.array(ids) - - install( - "numpy", - { - "array": _fake_numpy_array, - "ndarray": _FakeNumpyArray, - "float32": "float32", - }, - ) - install( - "faiss", - { - "read_index": lambda *args, **kwargs: _FakeIndex(), - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": _FakeIndex, - "IndexFlatIP": _FakeIndex, - "IndexIDMap": _FakeIndex, - "IndexIDMap2": _FakeIndex, - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - - -_install_optional_dependency_stubs() - -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -class _FakeCancelToken: - def raise_if_cancelled(self) -> None: - return None - - -class _FakePluginBridge: - def resolve_request_plugin_id(self, request_id: str) -> str: - return request_id.split(":", maxsplit=1)[0] - - -class _FakeSp: - def __init__(self) -> None: - self.store: dict[tuple[str, str, str], object] = {} - - async def get_async(self, scope, scope_id, key, default=None): - return self.store.get((scope, scope_id, key), default) - - async def put_async(self, scope, scope_id, key, value): - self.store[(scope, scope_id, key)] = value - - async def remove_async(self, scope, scope_id, key): - self.store.pop((scope, scope_id, key), None) - - async def range_get_async(self, scope, scope_id, prefix=None): - keys = sorted( - key - for current_scope, current_scope_id, key in self.store - if current_scope == scope - and current_scope_id == scope_id - and (prefix is None or key.startswith(prefix)) - ) - return [SimpleNamespace(key=key) for key in keys] - - -def _embedding_vector(text: str, *, rotation: int = 0) -> list[float]: - weights = { - "banana": [1.0, 0.0, 0.0, 0.1], - "smoothie": [0.7, 0.0, 0.0, 0.2], - "mango": [0.5, 0.0, 0.0, 0.0], - "ocean": [0.0, 1.0, 0.0, 0.1], - "blue": [0.0, 0.7, 0.0, 0.0], - "waves": [0.0, 0.5, 0.0, 0.0], - "alpha": [0.0, 0.0, 1.0, 0.0], - "memory": [0.0, 0.0, 0.4, 0.0], - "temporary": [0.0, 0.0, 0.0, 1.0], - } - values = [0.0, 0.0, 0.0, 0.0] - normalized = str(text).casefold() - for token, token_weights in weights.items(): - if token in normalized: - values = [ - current + delta - for current, delta in zip(values, token_weights, strict=True) - ] - if rotation: - rotation %= len(values) - values = values[-rotation:] + values[:-rotation] - norm = math.sqrt(sum(value * value for value in values)) - if norm <= 0: - return values - return [value / norm for value in values] - - -class _FakeEmbeddingProvider: - def __init__(self, provider_id: str, *, rotation: int = 0) -> None: - self.provider_id = provider_id - self.rotation = rotation - self.single_calls: list[str] = [] - self.batch_calls: list[list[str]] = [] - - def meta(self): - return SimpleNamespace(id=self.provider_id) - - async def get_embedding(self, text: str) -> list[float]: - self.single_calls.append(text) - return _embedding_vector(text, rotation=self.rotation) - - async def get_embeddings(self, texts: list[str]) -> list[list[float]]: - self.batch_calls.append(list(texts)) - return [_embedding_vector(text, rotation=self.rotation) for text in texts] - - def get_dim(self) -> int: - return 4 - - -class _FakeStarContext: - def __init__(self, providers: list[_FakeEmbeddingProvider] | None = None) -> None: - self._providers = { - provider.provider_id: provider for provider in (providers or []) - } - self._embedding_providers = list(providers or []) - - def get_provider_by_id(self, provider_id: str): - return self._providers.get(provider_id) - - def get_all_embedding_providers(self): - return list(self._embedding_providers) - - def get_all_stars(self): - return [] - - -async def _call( - bridge: CoreCapabilityBridge, - capability: str, - payload: dict[str, object], - *, - request_id: str, -) -> dict[str, object]: - result = await bridge.execute( - capability, - payload, - stream=False, - cancel_token=_FakeCancelToken(), - request_id=request_id, - ) - assert isinstance(result, dict) - return result - - -@pytest.fixture -def _patch_embedding_runtime(monkeypatch: pytest.MonkeyPatch) -> None: - provider_types = ( - type("FakeSTTProvider", (), {}), - type("FakeTTSProvider", (), {}), - _FakeEmbeddingProvider, - type("FakeRerankProvider", (), {}), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_provider_types", - lambda: provider_types, - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.provider._get_runtime_provider_types", - lambda: provider_types, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_memory_search_uses_hybrid_embeddings_and_updates_stats( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - _patch_embedding_runtime: None, -) -> None: - monkeypatch.chdir(tmp_path) - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_sp", - lambda: fake_sp, - ) - provider = _FakeEmbeddingProvider("embedding-main") - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext([provider]), - plugin_bridge=_FakePluginBridge(), - ) - - await _call( - bridge, - "memory.save", - {"key": "fruit-note", "value": {"content": "banana smoothie with mango"}}, - request_id="plugin-a:req-1", - ) - await _call( - bridge, - "memory.save", - {"key": "ocean-note", "value": {"content": "waves on the blue ocean"}}, - request_id="plugin-a:req-2", - ) - - result = await _call( - bridge, - "memory.search", - {"query": "banana smoothie", "limit": 1}, - request_id="plugin-a:req-3", - ) - assert result["items"][0]["key"] == "fruit-note" - assert result["items"][0]["match_type"] == "hybrid" - assert float(result["items"][0]["score"]) > 0.0 - # Batch calls order may vary due to SQL ORDER BY updated_at DESC - assert len(provider.batch_calls) == 1 - assert set(provider.batch_calls[0]) == { - "banana smoothie with mango", - "waves on the blue ocean", - } - assert provider.single_calls == ["banana smoothie"] - - stats = await _call(bridge, "memory.stats", {}, request_id="plugin-a:req-4") - assert stats["total_items"] == 2 - assert int(stats["total_bytes"]) > 0 - assert stats["plugin_id"] == "plugin-a" - assert stats["ttl_entries"] == 0 - assert stats["vector_backend"] in {"faiss", "exact"} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_memory_search_auto_falls_back_to_keyword_without_provider( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - _patch_embedding_runtime: None, -) -> None: - monkeypatch.chdir(tmp_path) - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_sp", - lambda: fake_sp, - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(), - plugin_bridge=_FakePluginBridge(), - ) - - await _call( - bridge, - "memory.save", - {"key": "alpha-key", "value": {"content": "blue ocean memory"}}, - request_id="plugin-a:req-1", - ) - - result = await _call( - bridge, - "memory.search", - {"query": "alpha", "mode": "auto"}, - request_id="plugin-a:req-2", - ) - assert result["items"] == [ - { - "key": "alpha-key", - "value": {"content": "blue ocean memory"}, - "score": 1.0, - "match_type": "keyword", - } - ] - - stats = await _call(bridge, "memory.stats", {}, request_id="plugin-a:req-3") - assert stats["total_items"] == 1 - assert stats["vector_backend"] in {"faiss", "exact"} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_memory_sidecars_are_scoped_per_plugin( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - _patch_embedding_runtime: None, -) -> None: - monkeypatch.chdir(tmp_path) - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_sp", - lambda: fake_sp, - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext([_FakeEmbeddingProvider("embedding-main")]), - plugin_bridge=_FakePluginBridge(), - ) - - await _call( - bridge, - "memory.save", - {"key": "shared", "value": {"content": "banana smoothie profile"}}, - request_id="plugin-a:req-1", - ) - await _call( - bridge, - "memory.save", - {"key": "shared", "value": {"content": "blue ocean profile"}}, - request_id="plugin-b:req-1", - ) - - plugin_a_result = await _call( - bridge, - "memory.search", - {"query": "banana smoothie", "limit": 1}, - request_id="plugin-a:req-2", - ) - plugin_b_result = await _call( - bridge, - "memory.search", - {"query": "blue ocean", "limit": 1}, - request_id="plugin-b:req-2", - ) - - assert plugin_a_result["items"][0]["value"] == { - "content": "banana smoothie profile" - } - assert plugin_b_result["items"][0]["value"] == {"content": "blue ocean profile"} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_memory_search_reembeds_when_provider_changes( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - _patch_embedding_runtime: None, -) -> None: - monkeypatch.chdir(tmp_path) - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_sp", - lambda: fake_sp, - ) - primary = _FakeEmbeddingProvider("embedding-main", rotation=0) - alternate = _FakeEmbeddingProvider("embedding-alt", rotation=1) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext([primary, alternate]), - plugin_bridge=_FakePluginBridge(), - ) - - await _call( - bridge, - "memory.save", - {"key": "topic", "value": {"content": "banana smoothie with mango"}}, - request_id="plugin-a:req-1", - ) - - await _call( - bridge, - "memory.search", - {"query": "banana smoothie"}, - request_id="plugin-a:req-2", - ) - # Verify the first provider was used - assert len(primary.batch_calls) >= 1 - - await _call( - bridge, - "memory.search", - {"query": "banana smoothie", "provider_id": "embedding-alt"}, - request_id="plugin-a:req-3", - ) - # Verify the second provider was used - assert len(alternate.batch_calls) >= 1 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_memory_ttl_entries_are_purged_during_search( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - _patch_embedding_runtime: None, -) -> None: - monkeypatch.chdir(tmp_path) - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_sp", - lambda: fake_sp, - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext([_FakeEmbeddingProvider("embedding-main")]), - plugin_bridge=_FakePluginBridge(), - ) - - await _call( - bridge, - "memory.save_with_ttl", - {"key": "temp", "value": {"content": "temporary note"}, "ttl_seconds": 60}, - request_id="plugin-a:req-1", - ) - before = await _call( - bridge, - "memory.search", - {"query": "temporary"}, - request_id="plugin-a:req-2", - ) - assert before["items"][0]["value"] == {"content": "temporary note"} - - # Note: Direct TTL expiration manipulation is not supported in the bridge API - # The purge happens automatically during search based on actual expiration times - # This test verifies the TTL entry was created and returned before expiration - stats = await _call(bridge, "memory.stats", {}, request_id="plugin-a:req-3") - assert stats["ttl_entries"] == 1 diff --git a/tests/test_sdk/unit/test_sdk_core_capability_bridge_regression.py b/tests/test_sdk/unit/test_sdk_core_capability_bridge_regression.py deleted file mode 100644 index 402ab2d88..000000000 --- a/tests/test_sdk/unit/test_sdk_core_capability_bridge_regression.py +++ /dev/null @@ -1,390 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import sqlite3 -import sys -import types -from datetime import datetime, timedelta, timezone -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - - -_install_optional_dependency_stubs() - -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -class _FakePluginBridge: - def __init__(self) -> None: - self.configs = {"ai_girlfriend": {"enable_morning": True}} - self.saved: list[tuple[str, dict[str, object]]] = [] - - def resolve_request_plugin_id(self, _request_id: str) -> str: - return "ai_girlfriend" - - def get_plugin_config(self, plugin_id: str) -> dict[str, object] | None: - return self.configs.get(plugin_id) - - def save_plugin_config( - self, - plugin_id: str, - config: dict[str, object], - ) -> dict[str, object]: - saved = dict(config) - self.configs[plugin_id] = saved - self.saved.append((plugin_id, saved)) - return saved - - def get_plugin_metadata(self, plugin_id: str) -> dict[str, object] | None: - return {"name": plugin_id} - - def list_plugin_metadata(self) -> list[dict[str, object]]: - return [{"name": "ai_girlfriend"}] - - def resolve_request_session(self, _request_id: str): - return SimpleNamespace(event=SimpleNamespace(unified_msg_origin="umo:test")) - - -class _FakeStarContext: - def get_all_stars(self): - return [] - - -class _FakeMemorySp: - def __init__(self) -> None: - self.store: dict[tuple[str, str, str], object] = {} - - async def get_async(self, scope, scope_id, key, default=None): - return self.store.get((scope, scope_id, key), default) - - async def put_async(self, scope, scope_id, key, value): - self.store[(scope, scope_id, key)] = value - - async def remove_async(self, scope, scope_id, key): - self.store.pop((scope, scope_id, key), None) - - async def range_get_async(self, scope, scope_id=None, key=None): - items = [] - for item_scope, item_scope_id, item_key in self.store: - if item_scope != scope: - continue - if scope_id is not None and item_scope_id != scope_id: - continue - if key is not None and item_key != key: - continue - items.append(SimpleNamespace(key=item_key)) - return items - - -class _FakeEmbeddingProvider: - def __init__(self, provider_id: str = "embedding-main") -> None: - self.provider_id = provider_id - - def meta(self): - return SimpleNamespace(id=self.provider_id) - - @staticmethod - def _vector_for_text(text: str) -> list[float]: - normalized = str(text).casefold() - if "banana" in normalized or "mango" in normalized: - return [1.0, 0.0] - if "ocean" in normalized or "blue" in normalized: - return [0.0, 1.0] - return [0.5, 0.5] - - async def get_embedding(self, text: str) -> list[float]: - return self._vector_for_text(text) - - async def get_embeddings(self, texts: list[str]) -> list[list[float]]: - return [self._vector_for_text(text) for text in texts] - - def get_dim(self) -> int: - return 2 - - -class _FakeMemoryStarContext(_FakeStarContext): - def __init__( - self, embedding_provider: _FakeEmbeddingProvider | None = None - ) -> None: - self.embedding_provider = embedding_provider - self._providers_by_id: dict[str, object] = {} - if embedding_provider is not None: - self._providers_by_id[embedding_provider.provider_id] = embedding_provider - - def get_provider_by_id(self, provider_id: str): - return self._providers_by_id.get(provider_id) - - def get_all_embedding_providers(self): - if self.embedding_provider is None: - return [] - return [self.embedding_provider] - - -class _FakeCancelToken: - def raise_if_cancelled(self) -> None: - return None - - -@pytest.mark.unit -def test_core_capability_bridge_keeps_runtime_router_methods() -> None: - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(), - plugin_bridge=_FakePluginBridge(), - ) - - assert CoreCapabilityBridge.register.__qualname__ == "CapabilityRouter.register" - assert len(bridge._registrations) > 0 - assert "metadata.get_plugin_config" in bridge._registrations - assert "metadata.save_plugin_config" in bridge._registrations - assert "conversation.unset_persona" in bridge._registrations - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_capability_bridge_serves_registered_plugin_config() -> None: - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(), - plugin_bridge=_FakePluginBridge(), - ) - - payload = {"name": "ai_girlfriend"} - result = await bridge.execute( - "metadata.get_plugin_config", - payload, - stream=False, - cancel_token=_FakeCancelToken(), - request_id="req-1", - ) - - assert result == {"config": {"enable_morning": True}} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_capability_bridge_saves_plugin_config() -> None: - plugin_bridge = _FakePluginBridge() - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(), - plugin_bridge=plugin_bridge, - ) - - result = await bridge.execute( - "metadata.save_plugin_config", - {"config": {"chat_scope_mode": "global_default"}}, - stream=False, - cancel_token=_FakeCancelToken(), - request_id="req-1", - ) - - assert result == {"config": {"chat_scope_mode": "global_default"}} - assert plugin_bridge.saved == [ - ("ai_girlfriend", {"chat_scope_mode": "global_default"}) - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_memory_search_uses_hybrid_ranking_and_runtime_stats( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - embedding_provider = _FakeEmbeddingProvider() - plugin_bridge = _FakePluginBridge() - bridge = CoreCapabilityBridge( - star_context=_FakeMemoryStarContext(embedding_provider), - plugin_bridge=plugin_bridge, - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_provider_types", - lambda: (object, object, _FakeEmbeddingProvider, object), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.provider._get_runtime_provider_types", - lambda: (object, object, _FakeEmbeddingProvider, object), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic.get_astrbot_plugin_data_path", - lambda: str(tmp_path / "plugin_data"), - ) - - await bridge._memory_save( - "req-1", - {"key": "fruit-note", "value": {"content": "banana smoothie with mango"}}, - None, - ) - await bridge._memory_save( - "req-1", - {"key": "ocean-note", "value": {"content": "blue ocean memory"}}, - None, - ) - - result = await bridge._memory_search( - "req-1", - {"query": "banana smoothie", "limit": 1}, - None, - ) - - assert result["items"] == [ - { - "key": "fruit-note", - "value": {"content": "banana smoothie with mango"}, - "score": 1.0, - "match_type": "hybrid", - } - ] - - stats = await bridge._memory_stats("req-1", {}, None) - assert stats["total_items"] == 2 - assert stats["indexed_items"] == 2 - assert stats["embedded_items"] == 2 - assert stats["dirty_items"] == 0 - assert stats["plugin_id"] == "ai_girlfriend" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_memory_sidecars_are_scoped_per_plugin( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - embedding_provider = _FakeEmbeddingProvider() - - class _RoutingPluginBridge(_FakePluginBridge): - def resolve_request_plugin_id(self, request_id: str) -> str: - if request_id == "req-a": - return "plugin-a" - if request_id == "req-b": - return "plugin-b" - return super().resolve_request_plugin_id(request_id) - - bridge = CoreCapabilityBridge( - star_context=_FakeMemoryStarContext(embedding_provider), - plugin_bridge=_RoutingPluginBridge(), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic._get_runtime_provider_types", - lambda: (object, object, _FakeEmbeddingProvider, object), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.provider._get_runtime_provider_types", - lambda: (object, object, _FakeEmbeddingProvider, object), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic.get_astrbot_plugin_data_path", - lambda: str(tmp_path / "plugin_data"), - ) - - await bridge._memory_save( - "req-a", - {"key": "alpha-note", "value": {"content": "banana memory"}}, - None, - ) - await bridge._memory_save( - "req-b", - {"key": "beta-note", "value": {"content": "blue ocean memory"}}, - None, - ) - - await bridge._memory_search("req-a", {"query": "banana"}, None) - - stats_a = await bridge._memory_stats("req-a", {}, None) - stats_b = await bridge._memory_stats("req-b", {}, None) - - assert stats_a["indexed_items"] == 1 - assert stats_a["embedded_items"] == 1 - assert stats_a["dirty_items"] == 0 - assert stats_b["indexed_items"] == 1 - assert stats_b["embedded_items"] == 0 - assert stats_b["dirty_items"] == 1 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_memory_ttl_restores_expiration_after_sidecar_reset( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - bridge = CoreCapabilityBridge( - star_context=_FakeMemoryStarContext(), - plugin_bridge=_FakePluginBridge(), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.basic.get_astrbot_plugin_data_path", - lambda: str(tmp_path / "plugin_data"), - ) - - await bridge._memory_save_with_ttl( - "req-1", - { - "key": "temp-note", - "value": {"content": "temporary note"}, - "ttl_seconds": 60, - }, - None, - ) - - backend = bridge._memory_backend_for_plugin("ai_girlfriend") - assert backend._db_path.exists() is True # noqa: SLF001 - with sqlite3.connect(backend._db_path) as conn: # noqa: SLF001 - row = conn.execute( - """ - SELECT expires_at - FROM memory_records - WHERE namespace = ? AND key = ? - """, - ("", "temp-note"), - ).fetchone() - assert row is not None - assert row[0] is not None - - bridge._memory_backends_by_plugin = {} - bridge._memory_index_by_plugin = {} - bridge._memory_dirty_keys_by_plugin = {} - bridge._memory_expires_at_by_plugin = {} - expired_at = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() - with sqlite3.connect(backend._db_path) as conn: # noqa: SLF001 - conn.execute( - """ - UPDATE memory_records - SET expires_at = ? - WHERE namespace = ? AND key = ? - """, - (expired_at, "", "temp-note"), - ) - conn.commit() - - result = await bridge._memory_get("req-1", {"key": "temp-note"}, None) - - assert result == {"value": None} diff --git a/tests/test_sdk/unit/test_sdk_legacy_process_stage_compat.py b/tests/test_sdk/unit/test_sdk_legacy_process_stage_compat.py deleted file mode 100644 index 0018899fe..000000000 --- a/tests/test_sdk/unit/test_sdk_legacy_process_stage_compat.py +++ /dev/null @@ -1,207 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import sys -import types -from collections.abc import AsyncGenerator -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - - -_install_optional_dependency_stubs() - -from astrbot.core.pipeline.process_stage.stage import ProcessStage -from astrbot.core.sdk_bridge import plugin_bridge as plugin_bridge_module -from astrbot.core.sdk_bridge.plugin_bridge import ( - SKIP_LEGACY_REPLIED, - SKIP_LEGACY_STOPPED, - SdkPluginBridge, -) - - -class _FakeEvent: - def __init__(self, *, stopped: bool = False, has_send_oper: bool = False) -> None: - self._extras = {"activated_handlers": ["legacy-handler"]} - self._stopped = stopped - self._has_send_oper = has_send_oper - self.call_llm = False - self.is_at_or_wake_command = True - self.unified_msg_origin = "test-platform:friend:session" - - def get_extra(self, key: str, default=None): - return self._extras.get(key, default) - - def set_extra(self, key: str, value) -> None: - self._extras[key] = value - - def stop_event(self) -> None: - self._stopped = True - - def is_stopped(self) -> bool: - return self._stopped - - def get_result(self): - return None - - def should_call_llm(self, call_llm: bool) -> None: - self.call_llm = call_llm - - -class _FakeStarContext: - def get_all_stars(self) -> list: - return [] - - -async def _drain(generator: AsyncGenerator[None, None] | None) -> int: - if generator is None: - return 0 - count = 0 - async for _ in generator: - count += 1 - return count - - -def _make_process_stage( - *, - sdk_bridge, - star_process, - agent_process, -) -> ProcessStage: - stage = ProcessStage() - stage.ctx = SimpleNamespace( - astrbot_config={"provider_settings": {"enable": True}}, - ) - stage.sdk_plugin_bridge = sdk_bridge - stage.star_request_sub_stage = SimpleNamespace(process=star_process) - stage.agent_sub_stage = SimpleNamespace(process=agent_process) - return stage - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_process_stage_preserves_legacy_stop_and_skips_sdk_and_llm() -> None: - sdk_bridge = SimpleNamespace(dispatch_message=AsyncMock()) - agent_process = AsyncMock() - - async def legacy_process(event): - event.stop_event() - yield None - - async def agent_process_gen(_event): - if False: # pragma: no cover - yield None - - stage = _make_process_stage( - sdk_bridge=sdk_bridge, - star_process=legacy_process, - agent_process=agent_process_gen, - ) - event = _FakeEvent() - - yielded = await _drain(stage.process(event)) - - assert yielded == 1 - assert event.is_stopped() is True - sdk_bridge.dispatch_message.assert_not_awaited() - assert event.call_llm is False - assert agent_process.await_count == 0 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_process_stage_keeps_default_llm_suppressed_after_legacy_reply() -> None: - sdk_bridge = SimpleNamespace( - dispatch_message=AsyncMock( - return_value=SimpleNamespace(sent_message=False, stopped=False) - ) - ) - agent_process = AsyncMock() - - async def legacy_process(event): - event._has_send_oper = True - yield None - - async def agent_process_gen(_event): - agent_process() - if False: # pragma: no cover - yield None - - stage = _make_process_stage( - sdk_bridge=sdk_bridge, - star_process=legacy_process, - agent_process=agent_process_gen, - ) - event = _FakeEvent() - - yielded = await _drain(stage.process(event)) - - assert yielded == 1 - sdk_bridge.dispatch_message.assert_awaited_once_with(event) - assert event._has_send_oper is True - assert event.call_llm is False - assert agent_process.await_count == 0 - - -@pytest.mark.unit -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("event", "expected_reason"), - [ - (_FakeEvent(stopped=True), SKIP_LEGACY_STOPPED), - (_FakeEvent(has_send_oper=True), SKIP_LEGACY_REPLIED), - ], -) -async def test_sdk_bridge_skips_sdk_execution_when_legacy_already_handled_event( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - event: _FakeEvent, - expected_reason: str, -) -> None: - monkeypatch.setattr( - plugin_bridge_module, - "get_astrbot_data_path", - lambda: str(tmp_path), - ) - - bridge = SdkPluginBridge(_FakeStarContext()) - - result = await bridge.dispatch_message(event) - - assert result.matched_handlers == [] - assert result.executed_handlers == [] - assert result.sent_message is False - assert result.stopped is False - assert result.skipped_reason == expected_reason diff --git a/tests/test_sdk/unit/test_sdk_llm_capabilities.py b/tests/test_sdk/unit/test_sdk_llm_capabilities.py deleted file mode 100644 index 1ce36458e..000000000 --- a/tests/test_sdk/unit/test_sdk_llm_capabilities.py +++ /dev/null @@ -1,630 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import base64 -import sys -import types -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - - -_install_optional_dependency_stubs() - -from astrbot_sdk.clients.llm import ChatMessage, LLMClient -from astrbot_sdk.errors import AstrBotError - -from astrbot.core.provider.entities import LLMResponse as CoreLLMResponse -from astrbot.core.provider.entities import TokenUsage -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -class _RecordingProxy: - def __init__( - self, - *, - call_output: dict | None = None, - stream_output: list[dict] | None = None, - ) -> None: - self.call_output = call_output or {"text": "ok"} - self.stream_output = stream_output or [] - self.calls: list[tuple[str, dict]] = [] - self.stream_calls: list[tuple[str, dict]] = [] - - async def call(self, capability: str, payload: dict) -> dict: - self.calls.append((capability, dict(payload))) - return dict(self.call_output) - - async def stream(self, capability: str, payload: dict): - self.stream_calls.append((capability, dict(payload))) - for item in self.stream_output: - yield dict(item) - - -class _FakeToken: - def raise_if_cancelled(self) -> None: - return None - - -class _FakeProvider: - def __init__( - self, - *, - text_response: CoreLLMResponse | None = None, - stream_responses: list[CoreLLMResponse] | None = None, - stream_exception: Exception | None = None, - ) -> None: - self.text_response = text_response or CoreLLMResponse( - role="assistant", - completion_text="ok", - ) - self.stream_responses = stream_responses or [] - self.stream_exception = stream_exception - self.text_chat_calls: list[dict] = [] - self.text_chat_stream_calls: list[dict] = [] - - async def text_chat(self, **kwargs) -> CoreLLMResponse: - self.text_chat_calls.append(dict(kwargs)) - return self.text_response - - async def text_chat_stream(self, **kwargs): - self.text_chat_stream_calls.append(dict(kwargs)) - if self.stream_exception is not None: - raise self.stream_exception - for response in self.stream_responses: - yield response - - -class _FakeStarContext: - def __init__( - self, - *, - provider_by_id: _FakeProvider | None = None, - using_provider: _FakeProvider | None = None, - rerank_providers: list[object] | None = None, - ) -> None: - self._provider_by_id = provider_by_id - self._using_provider = using_provider - self._rerank_providers = rerank_providers or [] - self.provider_by_id_calls: list[str] = [] - self.using_provider_calls: list[str | None] = [] - - def get_provider_by_id(self, provider_id: str): - self.provider_by_id_calls.append(provider_id) - return self._provider_by_id - - def get_using_provider(self, umo: str | None = None): - self.using_provider_calls.append(umo) - return self._using_provider - - def get_all_rerank_providers(self): - return list(self._rerank_providers) - - -class _FakePluginBridge: - def __init__(self, umo: str = "umo:test") -> None: - self._request_context = SimpleNamespace( - event=SimpleNamespace(unified_msg_origin=umo), - ) - - def resolve_request_session(self, _request_id: str): - return self._request_context - - -class _FakeSTTProvider: - def __init__(self) -> None: - self.calls: list[str] = [] - - async def get_text(self, audio_url: str) -> str: - self.calls.append(audio_url) - return f"text:{audio_url}" - - -class _FakeTTSProvider: - def __init__(self, *, support_stream: bool = True) -> None: - self.support_stream_value = support_stream - self.get_audio_calls: list[str] = [] - self.stream_inputs: list[str] = [] - - def support_stream(self) -> bool: - return self.support_stream_value - - async def get_audio(self, text: str) -> str: - self.get_audio_calls.append(text) - return f"/tmp/{text}.wav" - - async def get_audio_stream(self, text_queue, audio_queue) -> None: - while True: - item = await text_queue.get() - if item is None: - break - self.stream_inputs.append(item) - await audio_queue.put((item, f"audio:{item}".encode())) - await audio_queue.put(None) - - -class _FakeEmbeddingProvider: - def __init__(self) -> None: - self.single_calls: list[str] = [] - self.batch_calls: list[list[str]] = [] - - async def get_embedding(self, text: str) -> list[float]: - self.single_calls.append(text) - return [0.1, 0.2] - - async def get_embeddings(self, texts: list[str]) -> list[list[float]]: - self.batch_calls.append(list(texts)) - return [[float(index), float(index + 1)] for index, _ in enumerate(texts)] - - def get_dim(self) -> int: - return 2 - - -class _FakeRerankItem: - def __init__(self, index: int, relevance_score: float) -> None: - self.index = index - self.relevance_score = relevance_score - - -class _FakeRerankProvider: - def __init__(self) -> None: - self.calls: list[tuple[str, list[str], int | None]] = [] - - async def rerank( - self, - query: str, - documents: list[str], - top_n: int | None = None, - ) -> list[_FakeRerankItem]: - self.calls.append((query, list(documents), top_n)) - return [ - _FakeRerankItem(index=1, relevance_score=0.9), - _FakeRerankItem(index=0, relevance_score=0.3), - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_llm_client_prefers_contexts_and_omits_history_from_payload() -> None: - proxy = _RecordingProxy() - client = LLMClient(proxy) - tools = [ - { - "type": "function", - "function": { - "name": "lookup_weather", - "description": "Look up weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, - }, - } - ] - - await client.chat( - "hello", - history=[ChatMessage(role="user", content="from-history")], - contexts=[{"role": "assistant", "content": "from-contexts"}], - provider_id="provider-1", - tool_calls_result=[{"role": "tool", "content": "done"}], - image_urls=["https://example.com/a.png"], - tools=tools, - ) - - capability, payload = proxy.calls[0] - assert capability == "llm.chat" - assert payload["contexts"] == [{"role": "assistant", "content": "from-contexts"}] - assert "history" not in payload - assert payload["provider_id"] == "provider-1" - assert payload["tool_calls_result"] == [{"role": "tool", "content": "done"}] - assert payload["image_urls"] == ["https://example.com/a.png"] - assert payload["tools"] == tools - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_llm_client_chat_raw_keeps_old_fields_and_accepts_optional_extensions() -> ( - None -): - proxy = _RecordingProxy( - call_output={ - "text": "done", - "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, - "finish_reason": "stop", - "tool_calls": [], - "role": "assistant", - "reasoning_content": "thinking", - "reasoning_signature": "sig-1", - } - ) - client = LLMClient(proxy) - - response = await client.chat_raw( - "hello", - history=[ChatMessage(role="user", content="old")], - contexts=[{"role": "assistant", "content": "new"}], - ) - - assert response.text == "done" - assert response.usage == { - "input_tokens": 1, - "output_tokens": 2, - "total_tokens": 3, - } - assert response.finish_reason == "stop" - assert response.tool_calls == [] - assert response.role == "assistant" - assert response.reasoning_content == "thinking" - assert response.reasoning_signature == "sig-1" - - capability, payload = proxy.calls[0] - assert capability == "llm.chat_raw" - assert payload["contexts"] == [{"role": "assistant", "content": "new"}] - assert "history" not in payload - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_bridge_uses_explicit_provider_id() -> None: - provider = _FakeProvider( - text_response=CoreLLMResponse(role="assistant", completion_text="explicit") - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(provider_by_id=provider), - plugin_bridge=_FakePluginBridge(), - ) - - result = await bridge._llm_chat( - "req-1", - {"prompt": "hello", "provider_id": "provider-explicit"}, - None, - ) - - assert result == {"text": "explicit"} - assert provider.text_chat_calls[0]["prompt"] == "hello" - assert provider.text_chat_calls[0]["contexts"] is None - assert bridge._star_context.provider_by_id_calls == ["provider-explicit"] - assert bridge._star_context.using_provider_calls == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_bridge_prefers_contexts_over_history_without_mixing() -> None: - provider = _FakeProvider( - text_response=CoreLLMResponse(role="assistant", completion_text="session") - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(using_provider=provider), - plugin_bridge=_FakePluginBridge(umo="umo:session"), - ) - - await bridge._llm_chat( - "req-2", - { - "prompt": "hello", - "history": [{"role": "user", "content": "from-history"}], - "contexts": [{"role": "assistant", "content": "from-contexts"}], - "tool_calls_result": [{"role": "tool", "content": "done"}], - "image_urls": ["https://example.com/a.png"], - "tools": [ - { - "type": "function", - "function": { - "name": "lookup_weather", - "description": "Look up weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, - }, - } - ], - }, - None, - ) - - kwargs = provider.text_chat_calls[0] - assert kwargs["contexts"] == [{"role": "assistant", "content": "from-contexts"}] - assert kwargs["tool_calls_result"] == [{"role": "tool", "content": "done"}] - assert kwargs["image_urls"] == ["https://example.com/a.png"] - assert "history" not in kwargs - assert kwargs["func_tool"] is not None - assert kwargs["func_tool"].names() == ["lookup_weather"] - tool = kwargs["func_tool"].get_tool("lookup_weather") - assert tool is not None - assert tool.description == "Look up weather" - assert tool.parameters == { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - } - assert bridge._star_context.using_provider_calls == ["umo:session"] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_bridge_raises_when_no_provider_available() -> None: - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="No active chat provider is available"): - await bridge._llm_chat("req-3", {"prompt": "hello"}, None) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_bridge_rejects_non_chat_provider_for_explicit_provider_id() -> ( - None -): - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(provider_by_id=_FakeSTTProvider()), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="is not a chat provider"): - await bridge._llm_chat( - "req-3b", - {"prompt": "hello", "provider_id": "stt-provider"}, - None, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_bridge_chat_raw_keeps_old_fields_and_returns_optional_extensions() -> ( - None -): - provider = _FakeProvider( - text_response=CoreLLMResponse( - role="assistant", - completion_text="raw-text", - reasoning_content="reasoning", - reasoning_signature="sig-raw", - usage=TokenUsage(input_other=2, input_cached=1, output=4), - ) - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(using_provider=provider), - plugin_bridge=_FakePluginBridge(), - ) - - result = await bridge._llm_chat_raw("req-4", {"prompt": "hello"}, None) - - assert result["text"] == "raw-text" - assert result["usage"] == { - "input_tokens": 3, - "output_tokens": 4, - "total_tokens": 7, - } - assert result["finish_reason"] == "stop" - assert result["tool_calls"] == [] - assert result["role"] == "assistant" - assert result["reasoning_content"] == "reasoning" - assert result["reasoning_signature"] == "sig-raw" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_stream_chat_uses_real_stream_without_duplicate_final_text() -> ( - None -): - provider = _FakeProvider( - stream_responses=[ - CoreLLMResponse(role="assistant", completion_text="he", is_chunk=True), - CoreLLMResponse(role="assistant", completion_text="llo", is_chunk=True), - CoreLLMResponse(role="assistant", completion_text="hello", is_chunk=False), - ] - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(using_provider=provider), - plugin_bridge=_FakePluginBridge(), - ) - - execution = await bridge._llm_stream_chat( - "req-5", {"prompt": "hello"}, _FakeToken() - ) - chunks: list[dict] = [] - async for item in execution.iterator: - chunks.append(item) - - assert [item["text"] for item in chunks if "text" in item] == ["he", "llo"] - assert execution.finalize(chunks) == {"text": "hello"} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_stream_chat_falls_back_only_on_not_implemented_error() -> None: - provider = _FakeProvider( - text_response=CoreLLMResponse(role="assistant", completion_text="fallback"), - stream_exception=NotImplementedError(), - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(using_provider=provider), - plugin_bridge=_FakePluginBridge(), - ) - - execution = await bridge._llm_stream_chat( - "req-6", {"prompt": "hello"}, _FakeToken() - ) - chunks: list[dict] = [] - async for item in execution.iterator: - chunks.append(item) - - assert "".join(item.get("text", "") for item in chunks) == "fallback" - assert execution.finalize(chunks) == {"text": "fallback"} - assert len(provider.text_chat_stream_calls) == 1 - assert len(provider.text_chat_calls) == 1 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_llm_stream_chat_does_not_swallow_non_not_implemented_errors() -> ( - None -): - provider = _FakeProvider(stream_exception=RuntimeError("stream failed")) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(using_provider=provider), - plugin_bridge=_FakePluginBridge(), - ) - - execution = await bridge._llm_stream_chat( - "req-7", {"prompt": "hello"}, _FakeToken() - ) - - with pytest.raises(RuntimeError, match="stream failed"): - async for _item in execution.iterator: - pass - - assert len(provider.text_chat_stream_calls) == 1 - assert provider.text_chat_calls == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_provider_bridge_specialized_capabilities( - monkeypatch: pytest.MonkeyPatch, -) -> None: - stt_provider = _FakeSTTProvider() - tts_provider = _FakeTTSProvider() - embedding_provider = _FakeEmbeddingProvider() - rerank_provider = _FakeRerankProvider() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.provider._get_runtime_provider_types", - lambda: ( - _FakeSTTProvider, - _FakeTTSProvider, - _FakeEmbeddingProvider, - _FakeRerankProvider, - ), - ) - - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(provider_by_id=stt_provider), - plugin_bridge=_FakePluginBridge(), - ) - assert await bridge._provider_stt_get_text( - "req-stt", - {"provider_id": "stt-provider", "audio_url": "audio.wav"}, - None, - ) == {"text": "text:audio.wav"} - - bridge._star_context._provider_by_id = tts_provider - assert await bridge._provider_tts_get_audio( - "req-tts", - {"provider_id": "tts-provider", "text": "hello"}, - None, - ) == {"audio_path": "/tmp/hello.wav"} - assert await bridge._provider_tts_support_stream( - "req-tts-support", - {"provider_id": "tts-provider"}, - None, - ) == {"supported": True} - - execution = await bridge._provider_tts_get_audio_stream( - "req-tts-stream", - {"provider_id": "tts-provider", "text_chunks": ["hello", "sdk"]}, - _FakeToken(), - ) - streamed = [item async for item in execution.iterator] - assert [item["text"] for item in streamed] == ["hello", "sdk"] - assert [base64.b64decode(item["audio_base64"]) for item in streamed] == [ - b"audio:hello", - b"audio:sdk", - ] - assert tts_provider.stream_inputs == ["hello", "sdk"] - - bridge._star_context._provider_by_id = embedding_provider - assert await bridge._provider_embedding_get_embedding( - "req-embedding", - {"provider_id": "embedding-provider", "text": "hello"}, - None, - ) == {"embedding": [0.1, 0.2]} - assert await bridge._provider_embedding_get_embeddings( - "req-embedding-many", - {"provider_id": "embedding-provider", "texts": ["a", "b"]}, - None, - ) == {"embeddings": [[0.0, 1.0], [1.0, 2.0]]} - assert await bridge._provider_embedding_get_dim( - "req-embedding-dim", - {"provider_id": "embedding-provider"}, - None, - ) == {"dim": 2} - - bridge._star_context._provider_by_id = rerank_provider - assert await bridge._provider_rerank_rerank( - "req-rerank", - { - "provider_id": "rerank-provider", - "query": "hello", - "documents": ["doc-0", "doc-1"], - "top_n": 2, - }, - None, - ) == { - "results": [ - {"index": 1, "score": 0.9, "document": "doc-1"}, - {"index": 0, "score": 0.3, "document": "doc-0"}, - ] - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_provider_bridge_rejects_provider_type_mismatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.provider._get_runtime_provider_types", - lambda: ( - _FakeSTTProvider, - _FakeTTSProvider, - _FakeEmbeddingProvider, - _FakeRerankProvider, - ), - ) - bridge = CoreCapabilityBridge( - star_context=_FakeStarContext(provider_by_id=_FakeSTTProvider()), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="text_to_speech provider"): - await bridge._provider_tts_get_audio( - "req-mismatch", - {"provider_id": "wrong-provider", "text": "hello"}, - None, - ) diff --git a/tests/test_sdk/unit/test_sdk_mcp_capabilities.py b/tests/test_sdk/unit/test_sdk_mcp_capabilities.py deleted file mode 100644 index 1e31bbbc7..000000000 --- a/tests/test_sdk/unit/test_sdk_mcp_capabilities.py +++ /dev/null @@ -1,708 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import json -import sys -import types -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install("jieba", {"cut": lambda text, *_a, **_k: text.split()}) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - install( - "aiocqhttp", - { - "CQHttp": type("CQHttp", (), {}), - "Event": type("Event", (), {}), - }, - ) - install( - "aiocqhttp.exceptions", - {"ActionFailed": type("ActionFailed", (Exception,), {})}, - ) - - -_install_optional_dependency_stubs() - -from astrbot_sdk.clients.mcp import MCPServerRecord -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.llm.entities import LLMToolSpec -from astrbot_sdk.runtime.loader import PluginSpec - -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge -from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge -from tests.test_sdk.unit._mcp_contract import exercise_local_mcp_contract - - -class _FakeFunctionToolManager: - def __init__(self) -> None: - self.func_list: list[object] = [] - self._config = {"mcpServers": {}} - self.mcp_server_runtime_view: dict[str, object] = {} - - def load_mcp_config(self) -> dict[str, object]: - return json.loads(json.dumps(self._config)) - - def save_mcp_config(self, config: dict[str, object]) -> bool: - self._config = json.loads(json.dumps(config)) - return True - - async def enable_mcp_server( - self, - name: str, - config: dict[str, object], - *_, - **__, - ) -> None: - tools = [ - SimpleNamespace(name=str(tool_name)) - for tool_name in config.get("mock_tools", [f"{name}_tool"]) - ] - self.mcp_server_runtime_view[name] = SimpleNamespace( - client=SimpleNamespace(tools=tools, server_errlogs=[]), - ) - - async def disable_mcp_server(self, name: str | None = None, **_kwargs) -> None: - if name is None: - self.mcp_server_runtime_view.clear() - return - self.mcp_server_runtime_view.pop(name, None) - - -class _FakeCorePluginBridge: - def __init__(self, *, acknowledge_global_mcp_risk: bool = False) -> None: - self._acknowledge_global_mcp_risk = acknowledge_global_mcp_risk - self._local_servers = { - "sdk-demo": { - "demo": { - "name": "demo", - "scope": "local", - "active": True, - "running": True, - "config": {"mock_tools": ["lookup"]}, - "tools": ["lookup"], - "errlogs": [], - "last_error": None, - } - } - } - self._temporary_sessions: dict[str, dict[str, object]] = {} - - def resolve_request_plugin_id(self, _request_id: str) -> str: - return "sdk-demo" - - def resolve_request_session(self, _request_id: str): - return None - - def acknowledges_global_mcp_risk(self, plugin_id: str) -> bool: - return plugin_id == "sdk-demo" and self._acknowledge_global_mcp_risk - - def get_local_mcp_server(self, plugin_id: str, name: str): - return self._local_servers.get(plugin_id, {}).get(name) - - def list_local_mcp_servers(self, plugin_id: str): - return list(self._local_servers.get(plugin_id, {}).values()) - - async def enable_local_mcp_server( - self, plugin_id: str, name: str, *, timeout: float - ): - server = dict(self._local_servers[plugin_id][name]) - if float(server["config"].get("mock_connect_delay", 0.0)) > timeout: - raise TimeoutError( - f"Local MCP server '{name}' did not become ready in time" - ) - server["active"] = True - server["running"] = True - self._local_servers[plugin_id][name] = server - return server - - async def disable_local_mcp_server(self, plugin_id: str, name: str): - server = dict(self._local_servers[plugin_id][name]) - server["active"] = False - server["running"] = False - self._local_servers[plugin_id][name] = server - return server - - async def wait_for_local_mcp_server( - self, plugin_id: str, name: str, *, timeout: float - ): - server = self._local_servers[plugin_id][name] - delay = float(server["config"].get("mock_connect_delay", 0.0)) - if delay > timeout: - raise TimeoutError( - f"Local MCP server '{name}' did not become ready in time" - ) - server = dict(server) - server["running"] = True - self._local_servers[plugin_id][name] = server - return server - - async def open_temporary_mcp_session( - self, - plugin_id: str, - *, - name: str, - config: dict[str, object], - timeout: float, - ) -> tuple[str, list[str]]: - delay = float(config.get("mock_connect_delay", 0.0)) - if delay > timeout: - raise TimeoutError(f"MCP session '{name}' failed to connect in time") - session_id = f"{plugin_id}:session-1" - tools = [str(item) for item in config.get("mock_tools", [f"{name}_tool"])] - self._temporary_sessions[session_id] = { - "plugin_id": plugin_id, - "name": name, - "tools": tools, - "results": dict(config.get("mock_tool_results", {})), - } - return session_id, tools - - def get_temporary_mcp_session_tools( - self, plugin_id: str, session_id: str - ) -> list[str]: - session = self._temporary_sessions.get(session_id) - if session is None or session["plugin_id"] != plugin_id: - raise AstrBotError.invalid_input("Unknown MCP session") - return list(session["tools"]) - - async def call_temporary_mcp_tool( - self, - plugin_id: str, - *, - session_id: str, - tool_name: str, - arguments: dict[str, object], - ) -> dict[str, object]: - session = self._temporary_sessions.get(session_id) - if session is None or session["plugin_id"] != plugin_id: - raise AstrBotError.invalid_input("Unknown MCP session") - result = session["results"].get(tool_name) - if isinstance(result, dict): - return dict(result) - return {"content": f"mock:{tool_name}", "arguments": dict(arguments)} - - async def close_temporary_mcp_session( - self, plugin_id: str, session_id: str - ) -> None: - session = self._temporary_sessions.get(session_id) - if session is None or session["plugin_id"] != plugin_id: - return - self._temporary_sessions.pop(session_id, None) - - async def execute_local_mcp_tool( - self, - plugin_id: str, - *, - server_name: str, - tool_name: str, - tool_args: dict[str, object], - timeout_seconds: int = 60, - ) -> dict[str, object]: - return { - "content": f"{plugin_id}:{server_name}:{tool_name}:{timeout_seconds}:{tool_args}", - "success": True, - } - - def get_request_tool_specs(self, plugin_id: str) -> list[LLMToolSpec]: - server = self._local_servers[plugin_id]["demo"] - return [ - LLMToolSpec.create( - name=f"mcp.{server['name']}.lookup", - description="demo lookup", - parameters_schema={"type": "object", "properties": {}}, - handler_ref='{"server_name":"demo","tool_name":"lookup"}', - handler_capability="internal.mcp.local.execute", - ) - ] - - -class _CoreMCPBackend: - def __init__(self, bridge: CoreCapabilityBridge) -> None: - self._bridge = bridge - - async def get_server(self, name: str): - output = await self._bridge._mcp_local_get("req-local", {"name": name}, None) - return MCPServerRecord.from_payload(output["server"]) - - async def list_servers(self): - output = await self._bridge._mcp_local_list("req-local", {}, None) - return [ - record - for record in ( - MCPServerRecord.from_payload(item) for item in output["servers"] - ) - if record is not None - ] - - async def enable_server(self, name: str): - output = await self._bridge._mcp_local_enable( - "req-local", - {"name": name, "timeout": 0.2}, - None, - ) - return MCPServerRecord.from_payload(output["server"]) - - async def disable_server(self, name: str): - output = await self._bridge._mcp_local_disable( - "req-local", {"name": name}, None - ) - return MCPServerRecord.from_payload(output["server"]) - - async def wait_until_ready(self, name: str, *, timeout: float): - output = await self._bridge._mcp_local_wait_until_ready( - "req-local", - {"name": name, "timeout": timeout}, - None, - ) - return MCPServerRecord.from_payload(output["server"]) - - -def _build_core_bridge( - *, - acknowledge_global_mcp_risk: bool = False, - func_tool_manager: _FakeFunctionToolManager | None = None, - plugin_bridge: _FakeCorePluginBridge | None = None, -) -> CoreCapabilityBridge: - tool_manager = func_tool_manager or _FakeFunctionToolManager() - return CoreCapabilityBridge( - star_context=SimpleNamespace( - get_llm_tool_manager=lambda: tool_manager, - persona_manager=object(), - conversation_manager=object(), - kb_manager=object(), - get_all_stars=lambda: [], - ), - plugin_bridge=plugin_bridge - or _FakeCorePluginBridge( - acknowledge_global_mcp_risk=acknowledge_global_mcp_risk - ), - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_local_mcp_contract() -> None: - bridge = _build_core_bridge() - await exercise_local_mcp_contract(_CoreMCPBackend(bridge)) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_mcp_session_round_trip() -> None: - bridge = _build_core_bridge() - - opened = await bridge._mcp_session_open( - "req-local", - { - "name": "adhoc", - "config": { - "mock_tools": ["inspect"], - "mock_tool_results": {"inspect": {"ok": True}}, - }, - "timeout": 0.2, - }, - None, - ) - session_id = opened["session_id"] - assert opened["tools"] == ["inspect"] - - listed = await bridge._mcp_session_list_tools( - "req-local", - {"session_id": session_id}, - None, - ) - assert listed["tools"] == ["inspect"] - - called = await bridge._mcp_session_call_tool( - "req-local", - { - "session_id": session_id, - "tool_name": "inspect", - "args": {"q": "hello"}, - }, - None, - ) - assert called["result"] == {"ok": True} - - closed = await bridge._mcp_session_close( - "req-local", - {"session_id": session_id}, - None, - ) - assert closed == {} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_global_mcp_requires_ack_and_audits( - monkeypatch: pytest.MonkeyPatch, -) -> None: - func_tool_manager = _FakeFunctionToolManager() - bridge = _build_core_bridge( - acknowledge_global_mcp_risk=False, - func_tool_manager=func_tool_manager, - ) - - with pytest.raises(PermissionError): - await bridge._mcp_global_register( - "req-local", - { - "name": "global-demo", - "config": {"mock_tools": ["inspect"]}, - "timeout": 0.2, - }, - None, - ) - with pytest.raises(PermissionError): - await bridge._mcp_global_list("req-local", {}, None) - with pytest.raises(PermissionError): - await bridge._mcp_global_get("req-local", {"name": "global-demo"}, None) - - bridge = _build_core_bridge( - acknowledge_global_mcp_risk=True, - func_tool_manager=func_tool_manager, - ) - actions: list[dict[str, str]] = [] - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.mcp.logger.info", - lambda _message, payload: actions.append(dict(payload)), - ) - - registered = await bridge._mcp_global_register( - "req-local", - { - "name": "global-demo", - "config": {"mock_tools": ["inspect"]}, - "timeout": 0.2, - }, - None, - ) - assert registered["server"]["running"] is True - listed = await bridge._mcp_global_list("req-local", {}, None) - assert [item["name"] for item in listed["servers"]] == ["global-demo"] - fetched = await bridge._mcp_global_get( - "req-local", - {"name": "global-demo"}, - None, - ) - assert fetched["server"]["name"] == "global-demo" - - disabled = await bridge._mcp_global_disable( - "req-local", - {"name": "global-demo"}, - None, - ) - assert disabled["server"]["active"] is False - - enabled = await bridge._mcp_global_enable( - "req-local", - {"name": "global-demo", "timeout": 0.2}, - None, - ) - assert enabled["server"]["running"] is True - - removed = await bridge._mcp_global_unregister( - "req-local", - {"name": "global-demo"}, - None, - ) - assert removed["server"]["name"] == "global-demo" - assert [item["action"] for item in actions] == [ - "register", - "disable", - "enable", - "unregister", - ] - - -class _FakeWorkerSession: - bridge: SdkPluginBridge | None = None - - def __init__(self, *, plugin: PluginSpec, on_closed=None, **_kwargs) -> None: - self.plugin = plugin - self.on_closed = on_closed - self.handlers = [] - self.llm_tools = [] - self.agents = [] - self.issues = [] - self.peer = None - self._start_assertions: list[bool] = [] - - async def start(self) -> None: - bridge = self.__class__.bridge - if bridge is not None: - record = bridge._records[self.plugin.name] - self._start_assertions.append( - all( - not runtime.running for runtime in record.local_mcp_servers.values() - ) - ) - else: - self._start_assertions.append(True) - self.peer = SimpleNamespace( - remote_metadata={"acknowledge_global_mcp_risk": False} - ) - - def start_close_watch(self) -> None: - return None - - async def stop(self) -> None: - return None - - -class _FakeMCPClient: - created: list[_FakeMCPClient] = [] - - def __init__(self) -> None: - self.name: str | None = None - self.tools = [ - SimpleNamespace( - name="lookup", - description="Lookup item", - inputSchema={"type": "object", "properties": {"q": {"type": "string"}}}, - ) - ] - self.server_errlogs: list[str] = [] - self.process_pid = 4242 - self.cleaned = False - _FakeMCPClient.created.append(self) - - async def connect_to_server(self, _config: dict[str, object], _name: str) -> None: - return None - - async def list_tools_and_save(self): - return SimpleNamespace(tools=self.tools) - - async def cleanup(self) -> None: - self.cleaned = True - - async def call_tool_with_reconnect( - self, tool_name: str, arguments: dict[str, object], **_kwargs - ): - return SimpleNamespace( - content=[SimpleNamespace(text=f"{tool_name}:{arguments}")], - isError=False, - ) - - -def _plugin_spec(plugin_dir: Path, *, name: str = "sdk-demo") -> PluginSpec: - plugin_dir.mkdir(parents=True, exist_ok=True) - manifest_path = plugin_dir / "plugin.yaml" - manifest_path.write_text( - "name: sdk-demo\nauthor: tester\ndesc: demo\nversion: 0.1.0\nruntime:\n python: '3.11'\ncomponents: []\n", - encoding="utf-8", - ) - requirements_path = plugin_dir / "requirements.txt" - requirements_path.write_text("", encoding="utf-8") - return PluginSpec( - name=name, - plugin_dir=plugin_dir, - manifest_path=manifest_path, - requirements_path=requirements_path, - python_version="3.11", - manifest_data={ - "name": name, - "display_name": name, - "author": "tester", - "desc": "demo", - "version": "0.1.0", - }, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_plugin_bridge_loads_mcp_json_and_keeps_local_tools_plugin_scoped( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - data_root = tmp_path / "data" - plugin_data_root = data_root / "plugin_data" - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_data_path", - lambda: str(data_root), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_plugin_data_path", - lambda: str(plugin_data_root), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.WorkerSession", _FakeWorkerSession - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.MCPClient", _FakeMCPClient - ) - - func_tool_manager = _FakeFunctionToolManager() - bridge = SdkPluginBridge( - SimpleNamespace( - get_llm_tool_manager=lambda: func_tool_manager, - get_all_stars=lambda: [], - ) - ) - _FakeWorkerSession.bridge = bridge - bridge._publish_plugin_skills = lambda _plugin_id: None - bridge._persist_state_overrides = lambda: None - - async def _noop_register_schedule(_record) -> None: - return None - - bridge._register_schedule_handlers = _noop_register_schedule - - plugin_a = _plugin_spec(tmp_path / "plugin-a", name="plugin-a") - plugin_b = _plugin_spec(tmp_path / "plugin-b", name="plugin-b") - (plugin_a.plugin_dir / "mcp.json").write_text( - json.dumps({"mcpServers": {"alpha": {"command": "uvx", "args": ["alpha"]}}}), - encoding="utf-8", - ) - (plugin_b.plugin_dir / "mcp.json").write_text( - json.dumps({"mcpServers": {"beta": {"command": "uvx", "args": ["beta"]}}}), - encoding="utf-8", - ) - - await bridge._load_or_reload_plugin( - plugin_a, load_order=0, reset_restart_budget=True - ) - await bridge._load_or_reload_plugin( - plugin_b, load_order=1, reset_restart_budget=True - ) - - record_a = bridge._records["plugin-a"] - record_b = bridge._records["plugin-b"] - assert record_a.session._start_assertions == [True] - assert record_b.session._start_assertions == [True] - assert record_a.local_mcp_servers["alpha"].running is True - assert record_b.local_mcp_servers["beta"].running is True - assert [item.name for item in bridge.get_request_tool_specs("plugin-a")] == [ - "mcp.alpha.lookup" - ] - assert [item.name for item in bridge.get_request_tool_specs("plugin-b")] == [ - "mcp.beta.lookup" - ] - assert func_tool_manager.func_list == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_plugin_bridge_worker_close_cleans_local_mcp_runtimes( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _FakeMCPClient.created.clear() - data_root = tmp_path / "data" - plugin_data_root = data_root / "plugin_data" - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_data_path", - lambda: str(data_root), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_plugin_data_path", - lambda: str(plugin_data_root), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.WorkerSession", _FakeWorkerSession - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.MCPClient", _FakeMCPClient - ) - - bridge = SdkPluginBridge( - SimpleNamespace( - get_llm_tool_manager=lambda: _FakeFunctionToolManager(), - get_all_stars=lambda: [], - ) - ) - _FakeWorkerSession.bridge = bridge - bridge._publish_plugin_skills = lambda _plugin_id: None - bridge._persist_state_overrides = lambda: None - - async def _noop_register_schedule(_record) -> None: - return None - - bridge._register_schedule_handlers = _noop_register_schedule - - plugin = _plugin_spec(tmp_path / "plugin-demo", name="plugin-demo") - (plugin.plugin_dir / "mcp.json").write_text( - json.dumps({"mcpServers": {"demo": {"command": "uvx", "args": ["demo"]}}}), - encoding="utf-8", - ) - - await bridge._load_or_reload_plugin(plugin, load_order=0, reset_restart_budget=True) - bridge._records["plugin-demo"].restart_attempted = True - await bridge._handle_worker_closed("plugin-demo") - - assert _FakeMCPClient.created - assert all(client.cleaned for client in _FakeMCPClient.created) - assert bridge._records["plugin-demo"].local_mcp_servers["demo"].running is False - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_plugin_bridge_start_sweeps_stale_mcp_leases_before_reload( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - data_root = tmp_path / "data" - plugin_data_root = data_root / "plugin_data" - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_data_path", - lambda: str(data_root), - ) - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.get_astrbot_plugin_data_path", - lambda: str(plugin_data_root), - ) - lease_dir = plugin_data_root / "demo-plugin" / ".mcp_leases" - lease_dir.mkdir(parents=True, exist_ok=True) - lease_path = lease_dir / "demo.json" - lease_path.write_text( - json.dumps({"pid": 12345, "plugin_id": "demo-plugin", "server_name": "demo"}), - encoding="utf-8", - ) - killed: list[int] = [] - monkeypatch.setattr( - "astrbot.core.sdk_bridge.plugin_bridge.os.kill", - lambda pid, _sig: killed.append(pid), - ) - - bridge = SdkPluginBridge( - SimpleNamespace( - get_llm_tool_manager=lambda: _FakeFunctionToolManager(), - get_all_stars=lambda: [], - ) - ) - bridge._persist_state_overrides = lambda: None - - async def _fake_reload_all(*, reset_restart_budget: bool) -> None: - assert reset_restart_budget is True - - bridge.reload_all = _fake_reload_all - - await bridge.start() - - assert killed == [12345] - assert lease_path.exists() is False diff --git a/tests/test_sdk/unit/test_sdk_message_history_managers.py b/tests/test_sdk/unit/test_sdk_message_history_managers.py deleted file mode 100644 index 621f6098e..000000000 --- a/tests/test_sdk/unit/test_sdk_message_history_managers.py +++ /dev/null @@ -1,301 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import sys -import types -from dataclasses import dataclass -from datetime import datetime, timezone -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - install( - "aiocqhttp", - { - "CQHttp": type("CQHttp", (), {}), - "Event": type("Event", (), {}), - }, - ) - install( - "aiocqhttp.exceptions", - {"ActionFailed": type("ActionFailed", (Exception,), {})}, - ) - - -_install_optional_dependency_stubs() - -from astrbot_sdk.errors import AstrBotError - -from astrbot.core.message.components import Plain -from astrbot.core.platform.message_session import MessageSession -from astrbot.core.platform.message_type import MessageType -from astrbot.core.platform_message_history_mgr import ( - MessageHistoryPage, - MessageHistoryRecord, - MessageHistorySender, -) -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -@dataclass(slots=True) -class _FakeMessageHistoryManager: - append_calls: list[dict[str, object]] - list_calls: list[dict[str, object]] - get_calls: list[dict[str, object]] - delete_before_calls: list[dict[str, object]] - delete_after_calls: list[dict[str, object]] - delete_all_calls: list[MessageSession] - record: MessageHistoryRecord - - def __init__(self) -> None: - session = MessageSession( - platform_name="demo-platform", - message_type=MessageType.FRIEND_MESSAGE, - session_id="user-1", - ) - self.append_calls = [] - self.list_calls = [] - self.get_calls = [] - self.delete_before_calls = [] - self.delete_after_calls = [] - self.delete_all_calls = [] - self.record = MessageHistoryRecord( - id=7, - session=session, - sender=MessageHistorySender(sender_id="sender-1", sender_name="Tester"), - parts=[Plain("hello history", convert=False)], - metadata={"trace_id": "trace-1"}, - created_at=datetime(2026, 3, 22, 9, 0, tzinfo=timezone.utc), - updated_at=datetime(2026, 3, 22, 9, 1, tzinfo=timezone.utc), - idempotency_key="idem-1", - ) - - async def append(self, session: MessageSession, **kwargs) -> MessageHistoryRecord: - self.append_calls.append({"session": session, **kwargs}) - return self.record - - async def list( - self, - session: MessageSession, - *, - cursor: str | None = None, - limit: int = 50, - ) -> MessageHistoryPage: - self.list_calls.append({"session": session, "cursor": cursor, "limit": limit}) - return MessageHistoryPage(records=[self.record], next_cursor="6", total=3) - - async def get_by_id( - self, - session: MessageSession, - record_id: int, - ) -> MessageHistoryRecord | None: - self.get_calls.append({"session": session, "record_id": record_id}) - return self.record if record_id == self.record.id else None - - async def delete_before(self, session: MessageSession, *, before: datetime) -> int: - self.delete_before_calls.append({"session": session, "before": before}) - return 2 - - async def delete_after(self, session: MessageSession, *, after: datetime) -> int: - self.delete_after_calls.append({"session": session, "after": after}) - return 1 - - async def delete_all(self, session: MessageSession) -> int: - self.delete_all_calls.append(session) - return 3 - - -def _build_bridge( - message_history_manager: _FakeMessageHistoryManager, -) -> CoreCapabilityBridge: - return CoreCapabilityBridge( - star_context=SimpleNamespace( - message_history_manager=message_history_manager, - persona_manager=object(), - conversation_manager=object(), - kb_manager=object(), - ), - plugin_bridge=SimpleNamespace(resolve_request_session=lambda _request_id: None), - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_message_history_capabilities_round_trip() -> None: - manager = _FakeMessageHistoryManager() - bridge = _build_bridge(manager) - - descriptor_names = {item.name for item in bridge.descriptors()} - assert "message_history.list" in descriptor_names - assert "message_history.get_by_id" in descriptor_names - assert "message_history.append" in descriptor_names - assert "message_history.delete_before" in descriptor_names - assert "message_history.delete_after" in descriptor_names - assert "message_history.delete_all" in descriptor_names - - append_result = await bridge._message_history_append( - "req-append", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "sender": {"sender_id": "sender-1", "sender_name": "Tester"}, - "parts": [{"type": "text", "data": {"text": "hello history"}}], - "metadata": {"trace_id": "trace-1"}, - "idempotency_key": "idem-1", - }, - None, - ) - assert append_result["record"] is not None - assert append_result["record"]["session"] == { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - } - assert append_result["record"]["sender"] == { - "sender_id": "sender-1", - "sender_name": "Tester", - } - assert append_result["record"]["parts"] == [ - {"type": "text", "data": {"text": "hello history"}} - ] - append_call = manager.append_calls[-1] - append_session = append_call["session"] - assert isinstance(append_session, MessageSession) - assert append_session.platform_id == "demo-platform" - assert append_session.message_type == MessageType.FRIEND_MESSAGE - assert append_session.session_id == "user-1" - - list_result = await bridge._message_history_list( - "req-list", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "cursor": "10", - "limit": 1, - }, - None, - ) - assert list_result["page"]["next_cursor"] == "6" - assert list_result["page"]["total"] == 3 - assert manager.list_calls[-1]["cursor"] == "10" - assert manager.list_calls[-1]["limit"] == 1 - - get_result = await bridge._message_history_get_by_id( - "req-get", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "record_id": 7, - }, - None, - ) - assert get_result["record"]["id"] == 7 - assert manager.get_calls[-1]["record_id"] == 7 - - deleted_before = await bridge._message_history_delete_before( - "req-delete-before", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "before": "2026-03-22T09:30:00+00:00", - }, - None, - ) - assert deleted_before == {"deleted_count": 2} - assert manager.delete_before_calls[-1]["before"] == datetime( - 2026, 3, 22, 9, 30, tzinfo=timezone.utc - ) - - deleted_after = await bridge._message_history_delete_after( - "req-delete-after", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "after": "2026-03-22T08:59:00+00:00", - }, - None, - ) - assert deleted_after == {"deleted_count": 1} - - deleted_all = await bridge._message_history_delete_all( - "req-delete-all", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - } - }, - None, - ) - assert deleted_all == {"deleted_count": 3} - assert manager.delete_all_calls[-1].session_id == "user-1" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_message_history_validates_typed_session_payload() -> None: - bridge = _build_bridge(_FakeMessageHistoryManager()) - - with pytest.raises(AstrBotError, match="require a session object"): - await bridge._message_history_list( - "req-1", {"session": "demo:private:user"}, None - ) - - with pytest.raises(AstrBotError, match="requires limit >= 1"): - await bridge._message_history_list( - "req-2", - { - "session": { - "platform_id": "demo-platform", - "message_type": "private", - "session_id": "user-1", - }, - "limit": 0, - }, - None, - ) diff --git a/tests/test_sdk/unit/test_sdk_message_objects.py b/tests/test_sdk/unit/test_sdk_message_objects.py deleted file mode 100644 index cc8159f1d..000000000 --- a/tests/test_sdk/unit/test_sdk_message_objects.py +++ /dev/null @@ -1,809 +0,0 @@ -# ruff: noqa: E402, I001 -from __future__ import annotations - -import asyncio -import sys -import types -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - - -_install_optional_dependency_stubs() - -from astrbot.core.message.components import File as CoreFile -from astrbot.core.message.components import Plain as CorePlain -from astrbot.core.message.components import Reply as CoreReply -from astrbot.core.sdk_bridge.event_converter import EventConverter -from astrbot_sdk import MessageEvent -from astrbot_sdk import message_components as sdk_message_components -from astrbot_sdk._plugin_logger import PluginLogEntry -from astrbot_sdk._star_runtime import bind_star_runtime -from astrbot_sdk.context import Context -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.message_components import ( - File, - Image, - Plain, - UnknownComponent, - payloads_to_components, -) -from astrbot_sdk.message_result import EventResultType, MessageChain, MessageEventResult -from astrbot_sdk.protocol.descriptors import SessionRef -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher - - -class _DummyPeer: - def __init__(self) -> None: - self.remote_peer = {"name": "dummy-core"} - self.remote_capability_map = { - "platform.send": SimpleNamespace(supports_stream=False), - "platform.send_chain": SimpleNamespace(supports_stream=False), - "platform.send_by_session": SimpleNamespace(supports_stream=False), - "platform.get_group": SimpleNamespace(supports_stream=False), - "platform.list_instances": SimpleNamespace(supports_stream=False), - "registry.command.register": SimpleNamespace(supports_stream=False), - "system.event.react": SimpleNamespace(supports_stream=False), - "system.event.send_typing": SimpleNamespace(supports_stream=False), - "system.event.send_streaming": SimpleNamespace(supports_stream=False), - "system.event.send_streaming_chunk": SimpleNamespace(supports_stream=False), - "system.event.send_streaming_close": SimpleNamespace(supports_stream=False), - "system.file.register": SimpleNamespace(supports_stream=False), - "system.file.handle": SimpleNamespace(supports_stream=False), - } - self.sent_messages: list[dict] = [] - self.event_actions: list[dict] = [] - self.command_registrations: list[dict] = [] - self.platform_instances = [ - { - "id": "demo", - "name": "Demo Platform", - "type": "demo", - "status": "running", - } - ] - self._open_streams: dict[str, dict] = {} - self._file_tokens: dict[str, str] = {} - - async def invoke(self, capability: str, payload: dict, *, stream: bool = False): - if stream: - raise ValueError("stream unsupported in dummy peer") - if capability == "platform.send": - self.sent_messages.append( - { - "kind": "text", - "session": payload.get("session"), - "text": payload.get("text"), - } - ) - return {"message_id": "text-1"} - if capability == "platform.send_chain": - self.sent_messages.append( - { - "kind": "chain", - "session": payload.get("session"), - "chain": payload.get("chain"), - } - ) - return {"message_id": "chain-1"} - if capability == "platform.send_by_session": - self.sent_messages.append( - { - "kind": "chain", - "session": payload.get("session"), - "chain": payload.get("chain"), - } - ) - return {"message_id": "proactive-1"} - if capability == "platform.get_group": - session = str(payload.get("session", "")) - if ":group:" not in session: - return {"group": None} - return { - "group": { - "group_id": "room-7", - "group_name": "Room 7", - "group_avatar": "", - "group_owner": "owner-1", - "group_admins": ["admin-1"], - "members": [ - { - "user_id": "member-1", - "nickname": "Member 1", - "role": "member", - } - ], - } - } - if capability == "platform.list_instances": - return {"platforms": list(self.platform_instances)} - if capability == "registry.command.register": - self.command_registrations.append(dict(payload)) - return {} - if capability == "system.event.react": - self.event_actions.append( - {"action": "react", "emoji": payload.get("emoji")} - ) - return {"supported": True} - if capability == "system.event.send_typing": - self.event_actions.append({"action": "send_typing"}) - return {"supported": True} - if capability == "system.event.send_streaming": - stream_id = f"stream-{len(self._open_streams) + 1}" - self._open_streams[stream_id] = { - "chunks": [], - "use_fallback": payload.get("use_fallback"), - } - return {"supported": True, "stream_id": stream_id} - if capability == "system.event.send_streaming_chunk": - stream_id = str(payload.get("stream_id")) - self._open_streams[stream_id]["chunks"].append( - {"chain": payload.get("chain")} - ) - return {} - if capability == "system.event.send_streaming_close": - stream_id = str(payload.get("stream_id")) - stream = self._open_streams.pop(stream_id) - self.event_actions.append( - { - "action": "send_streaming", - "chunks": stream["chunks"], - "use_fallback": stream["use_fallback"], - } - ) - return {"supported": True} - if capability == "system.file.register": - token = f"file-{len(self._file_tokens) + 1}" - self._file_tokens[token] = str(payload.get("path", "")) - return { - "token": token, - "url": f"https://callback.example/api/file/{token}", - } - if capability == "system.file.handle": - token = str(payload.get("token", "")) - path = self._file_tokens.pop(token) - return {"path": path} - raise AssertionError(f"unexpected capability: {capability}") - - async def invoke_stream(self, capability: str, payload: dict): - raise AssertionError(f"unexpected stream capability: {capability}") - - -@pytest.mark.unit -def test_payload_to_components_and_event_local_state() -> None: - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "message_outline": "hello [UnknownComponent]", - "sent_message_outline": "assistant reply", - "messages": [ - {"type": "text", "data": {"text": "hello"}}, - {"type": "mystery", "data": {"payload": 1}}, - ], - "sent_messages": [ - {"type": "text", "data": {"text": "assistant reply"}}, - ], - "extras": {"seed": "value", "local": "seed"}, - "host_extras": {"seed": "value"}, - "sdk_local_extras": {"local": "seed"}, - } - ) - - messages = event.get_messages() - sent_messages = event.get_sent_messages() - assert len(messages) == 2 - assert isinstance(messages[0], Plain) - assert isinstance(messages[1], UnknownComponent) - assert len(sent_messages) == 1 - assert isinstance(sent_messages[0], Plain) - assert event.get_message_outline() == "hello [UnknownComponent]" - assert event.get_sent_message_outline() == "assistant reply" - assert event.get_extra("seed") == "value" - assert event.get_extra("local") == "seed" - - event.set_extra("local", 42) - assert event.get_extra("local") == 42 - assert event.get_extra()["local"] == 42 - event.clear_extra() - assert event.get_extra("local", "missing") == "missing" - - empty_result = event.make_result() - assert empty_result.type is EventResultType.EMPTY - assert empty_result.chain.components == [] - - image_result = event.image_result("https://example.com/a.png") - assert image_result.type is EventResultType.CHAIN - assert isinstance(image_result.chain.components[0], Image) - - chain_result = event.chain_result([Plain("sdk", convert=False)]) - assert chain_result.type is EventResultType.CHAIN - assert chain_result.chain.get_plain_text() == "sdk" - - -@pytest.mark.unit -def test_message_event_to_payload_drops_non_serializable_sdk_local_extras() -> None: - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "extras": {"seed": "value"}, - "host_extras": {"seed": "value"}, - "sdk_local_extras": {}, - } - ) - - event.set_extra("persisted", "ok") - event.set_extra("bad", object()) - - payload = event.to_payload() - - assert payload["extras"] == {"seed": "value", "persisted": "ok"} - assert payload["sdk_local_extras"] == {"persisted": "ok"} - assert "bad" not in payload["sdk_local_extras"] - - -@pytest.mark.unit -def test_payloads_to_components_unknown_fallback() -> None: - components = payloads_to_components( - [ - {"type": "text", "data": {"text": "hi"}}, - {"type": "unknown-segment", "data": {"foo": "bar"}}, - ] - ) - - assert isinstance(components[0], Plain) - assert isinstance(components[1], UnknownComponent) - assert components[1].toDict() == { - "type": "unknown-segment", - "data": {"foo": "bar"}, - } - - -@pytest.mark.unit -def test_reply_component_roundtrip_keeps_chain_and_metadata() -> None: - payload = { - "type": "reply", - "data": { - "id": "reply-1", - "sender_id": "user-9", - "sender_nickname": "Tester", - "message_str": "quoted text", - "chain": [{"type": "text", "data": {"text": "quoted text"}}], - }, - } - - component = sdk_message_components.payload_to_component(payload) - - assert isinstance(component, sdk_message_components.Reply) - assert component.sender_id == "user-9" - assert component.message_str == "quoted text" - assert len(component.chain) == 1 - assert isinstance(component.chain[0], Plain) - normalized = sdk_message_components.component_to_payload_sync(component) - assert normalized["type"] == "reply" - assert normalized["data"]["id"] == "reply-1" - assert normalized["data"]["sender_id"] == "user-9" - assert normalized["data"]["sender_nickname"] == "Tester" - assert normalized["data"]["message_str"] == "quoted text" - assert normalized["data"]["chain"] == payload["data"]["chain"] - - -@pytest.mark.unit -def test_event_converter_serializes_core_reply_chain() -> None: - reply = CoreReply( - id="reply-2", - sender_id="user-8", - sender_nickname="Quoted", - message_str="quoted core text", - chain=[CorePlain(text="quoted core text")], - ) - - class _CoreEvent: - is_wake = False - is_at_or_wake_command = False - - def get_message_type(self): - return SimpleNamespace(value="private") - - def get_message_str(self) -> str: - return "hello" - - def get_sender_id(self) -> str: - return "user-1" - - def get_group_id(self) -> str: - return "" - - def get_platform_name(self) -> str: - return "demo" - - def get_platform_id(self) -> str: - return "demo" - - def get_self_id(self) -> str: - return "bot-1" - - def get_sender_name(self) -> str: - return "Sender" - - def is_admin(self) -> bool: - return False - - def get_message_outline(self) -> str: - return "hello" - - def get_extra(self) -> dict[str, object]: - return {} - - @property - def unified_msg_origin(self) -> str: - return "demo:private:user-1" - - def get_messages(self): - return [reply] - - payload = EventConverter.core_to_sdk( - _CoreEvent(), - dispatch_token="dispatch-1", - plugin_id="sdk-demo", - request_id="req-1", - ) - - reply_payload = payload["messages"][0] - assert reply_payload["type"] == "reply" - assert reply_payload["data"]["sender_id"] == "user-8" - assert reply_payload["data"]["message_str"] == "quoted core text" - assert reply_payload["data"]["chain"] == [ - {"type": "text", "data": {"text": "quoted core text"}} - ] - - -@pytest.mark.unit -def test_file_component_roundtrip_accepts_legacy_core_payload() -> None: - payload = sdk_message_components.component_to_payload_sync( - CoreFile(name="sample.txt", file="C:/tmp/sample.txt") - ) - - component = sdk_message_components.payload_to_component(payload) - - assert isinstance(component, File) - assert component.file == "C:/tmp/sample.txt" - assert component.toDict() == { - "type": "file", - "data": {"name": "sample.txt", "file": "C:/tmp/sample.txt"}, - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_message_component_file_methods( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - sample = tmp_path / "sample.txt" - sample.write_text("hello", encoding="utf-8") - - image = Image.fromFileSystem(str(sample)) - assert await image.convert_to_file_path() == str(sample.resolve()) - - file_component = File(name="sample.txt", file=str(sample)) - assert await file_component.get_file() == str(sample.resolve()) - - async def fake_register_file_to_service(path: str) -> str: - assert path == str(sample.resolve()) - return "https://callback.example/api/file/token-123" - - monkeypatch.setattr( - sdk_message_components, - "_register_file_to_service", - fake_register_file_to_service, - ) - - assert ( - await image.register_to_file_service() - == "https://callback.example/api/file/token-123" - ) - assert ( - await file_component.register_to_file_service() - == "https://callback.example/api/file/token-123" - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_message_component_file_service_requires_runtime_context( - tmp_path: Path, -) -> None: - sample = tmp_path / "sample.txt" - sample.write_text("hello", encoding="utf-8") - image = Image.fromFileSystem(str(sample)) - - with pytest.raises(RuntimeError, match="runtime context"): - await image.register_to_file_service() - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_message_component_file_service_uses_current_runtime_context( - tmp_path: Path, -) -> None: - sample = tmp_path / "sample.txt" - sample.write_text("hello", encoding="utf-8") - image = Image.fromFileSystem(str(sample)) - ctx = Context(peer=_DummyPeer(), plugin_id="sdk-demo") - - with bind_star_runtime(None, ctx): - url = await image.register_to_file_service() - - assert url == "https://callback.example/api/file/file-1" - token = await ctx.files.register_file(str(sample)) - assert token == "file-2" - assert await ctx.files.handle_file(token) == str(sample) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_event_actions_and_send_chain_with_mock_context() -> None: - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "target": SessionRef(conversation_id="demo:private:user-1").to_payload(), - }, - context=ctx, - ) - - assert await event.react("👍") is True - assert await event.send_typing() is True - - async def generator(): - yield "sdk" - yield [Plain(" stream", convert=False)] - - assert await event.send_streaming(generator(), use_fallback=True) is True - - await ctx.platform.send_chain(event.session_id, MessageChain([Plain("chain")])) - - assert [item["action"] for item in peer.event_actions] == [ - "react", - "send_typing", - "send_streaming", - ] - assert peer.event_actions[-1]["chunks"] == [ - {"chain": [{"type": "text", "data": {"text": "sdk"}}]}, - {"chain": [{"type": "text", "data": {"text": " stream"}}]}, - ] - assert peer.sent_messages[-1]["kind"] == "chain" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_platform_send_by_session_accepts_existing_payload_shapes() -> None: - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - - await ctx.platform.send_by_session( - "demo:private:user-2", - [{"type": "text", "data": {"text": "dict-payload"}}], - ) - await ctx.platform.send_by_session( - "demo:private:user-3", - MessageChain([Plain("message-chain", convert=False)]), - ) - await ctx.platform.send_by_session( - "demo:private:user-4", - [Plain("component-list", convert=False)], - ) - await ctx.platform.send_by_id("demo", "user-5", "plain-text") - - assert peer.sent_messages[0] == { - "kind": "chain", - "session": "demo:private:user-2", - "chain": [{"type": "text", "data": {"text": "dict-payload"}}], - } - assert peer.sent_messages[1]["chain"] == [ - {"type": "text", "data": {"text": "message-chain"}} - ] - assert peer.sent_messages[2]["chain"] == [ - {"type": "text", "data": {"text": "component-list"}} - ] - assert peer.sent_messages[3] == { - "kind": "chain", - "session": "demo:private:user-5", - "chain": [{"type": "text", "data": {"text": "plain-text"}}], - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_context_p0_7_register_commands_and_platform_facade() -> None: - peer = _DummyPeer() - ctx = Context( - peer=peer, - plugin_id="sdk-demo", - source_event_payload={"event_type": "astrbot_loaded"}, - ) - - await ctx.register_commands( - "hello", - "sdk-demo:demo.handler", - desc="demo command", - priority=7, - use_regex=False, - ) - platform = await ctx.get_platform("demo") - assert platform is not None - assert platform.id == "demo" - assert platform.status == "running" - assert await ctx.get_platform_inst("missing") is None - - await platform.send_by_id("user-99", "hello from facade") - - assert peer.command_registrations == [ - { - "command_name": "hello", - "handler_full_name": "sdk-demo:demo.handler", - "source_event_type": "astrbot_loaded", - "desc": "demo command", - "priority": 7, - "use_regex": False, - "ignore_prefix": False, - } - ] - assert peer.sent_messages[-1]["session"] == "demo:private:user-99" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_context_register_commands_requires_startup_event() -> None: - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - - with pytest.raises(AstrBotError, match="astrbot_loaded/platform_loaded"): - await ctx.register_commands("hello", "sdk-demo:demo.handler") - - with pytest.raises(AstrBotError, match="ignore_prefix=True"): - startup_ctx = Context( - peer=peer, - plugin_id="sdk-demo", - source_event_payload={"type": "platform_loaded"}, - ) - await startup_ctx.register_commands( - "hello", - "sdk-demo:demo.handler", - ignore_prefix=True, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_context_register_commands_rejects_bool_priority() -> None: - peer = _DummyPeer() - ctx = Context( - peer=peer, - plugin_id="sdk-demo", - source_event_payload={"event_type": "astrbot_loaded"}, - ) - - with pytest.raises(AstrBotError, match="priority must be an integer"): - await ctx.register_commands( - "hello", - "sdk-demo:demo.handler", - priority=True, - ) - - assert peer.command_registrations == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_context_register_task_logs_background_exceptions() -> None: - class _ProbeLogger: - def __init__(self) -> None: - self.exception_calls: list[ - tuple[tuple[object, ...], dict[str, object]] - ] = [] - self.debug_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] - - def exception(self, *args, **kwargs) -> None: - self.exception_calls.append((args, kwargs)) - - def debug(self, *args, **kwargs) -> None: - self.debug_calls.append((args, kwargs)) - - async def _boom() -> None: - raise RuntimeError("boom") - - logger = _ProbeLogger() - ctx = Context(peer=_DummyPeer(), plugin_id="sdk-demo", logger=logger) - task = await ctx.register_task(_boom(), "probe-task") - - await asyncio.sleep(0) - await asyncio.sleep(0) - assert task.done() is True - assert len(logger.exception_calls) == 1 - msg, plugin_id, desc = logger.exception_calls[0][0] - assert "background task failed" in str(msg).lower() - assert plugin_id == "sdk-demo" - assert desc == "probe-task" - assert logger.debug_calls == [] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_context_logger_watch_streams_current_plugin_logs() -> None: - ctx = Context(peer=_DummyPeer(), plugin_id="sdk-demo") - watcher = ctx.logger.watch() - - async def _next_entry() -> PluginLogEntry: - return await watcher.__anext__() - - pending = asyncio.create_task(_next_entry()) - await asyncio.sleep(0) - ctx.logger.info("hello {}", "sdk") - entry = await pending - - assert entry.plugin_id == "sdk-demo" - assert entry.level == "INFO" - assert entry.message == "hello sdk" - - await watcher.aclose() - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_message_event_get_group_returns_group_only_for_group_session() -> None: - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - group_event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:group:room-7", - "platform": "demo", - "platform_id": "demo", - "message_type": "group", - "target": SessionRef(conversation_id="demo:group:room-7").to_payload(), - }, - context=ctx, - ) - private_event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "target": SessionRef(conversation_id="demo:private:user-1").to_payload(), - }, - context=ctx, - ) - - group = await group_event.get_group() - private_group = await private_event.get_group() - - assert group is not None - assert group["group_id"] == "room-7" - assert private_group is None - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_send_streaming_pushes_chunks_incrementally() -> None: - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "target": SessionRef(conversation_id="demo:private:user-1").to_payload(), - }, - context=ctx, - ) - - async def generator(): - yield "sdk" - assert peer._open_streams["stream-1"]["chunks"] == [ - {"chain": [{"type": "text", "data": {"text": "sdk"}}]} - ] - yield [Plain(" stream", convert=False)] - - assert await event.send_streaming(generator(), use_fallback=True) is True - assert peer.event_actions[-1]["chunks"] == [ - {"chain": [{"type": "text", "data": {"text": "sdk"}}]}, - {"chain": [{"type": "text", "data": {"text": " stream"}}]}, - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_handler_dispatcher_normalizes_sdk_result_objects() -> None: - dispatcher = HandlerDispatcher.__new__(HandlerDispatcher) - peer = _DummyPeer() - ctx = Context(peer=peer, plugin_id="sdk-demo") - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "demo:private:user-1", - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "target": SessionRef(conversation_id="demo:private:user-1").to_payload(), - }, - context=ctx, - ) - - assert ( - await dispatcher._send_result( # noqa: SLF001 - MessageEventResult( - type=EventResultType.CHAIN, - chain=MessageChain([Plain("from-result")]), - ), - event, - ctx, - ) - is True - ) - assert ( - await dispatcher._send_result( # noqa: SLF001 - MessageChain([Plain("from-chain")]), - event, - ctx, - ) - is True - ) - assert ( - await dispatcher._send_result( # noqa: SLF001 - [Plain("from-list")], - event, - ctx, - ) - is True - ) - - assert [item["kind"] for item in peer.sent_messages] == ["chain", "chain", "chain"] diff --git a/tests/test_sdk/unit/test_sdk_native_command_registration.py b/tests/test_sdk/unit/test_sdk_native_command_registration.py deleted file mode 100644 index 47e1ea4c4..000000000 --- a/tests/test_sdk/unit/test_sdk_native_command_registration.py +++ /dev/null @@ -1,351 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -from types import SimpleNamespace - -import pytest -from astrbot_sdk.llm.entities import LLMToolSpec -from astrbot_sdk.protocol.descriptors import ( - CommandRouteSpec, - CommandTrigger, - EventTrigger, - HandlerDescriptor, - MessageTrigger, - PlatformFilterSpec, - ScheduleTrigger, -) - -from astrbot.core.sdk_bridge.plugin_bridge import SdkHandlerRef, SdkPluginBridge - -pytest_plugins = ( - "tests.fixtures.mocks.discord", - "tests.fixtures.mocks.telegram", -) - - -class _BridgeStarContext: - def __init__(self) -> None: - self.registered_web_apis = [] - self.cron_manager = None - - def get_all_stars(self) -> list[object]: - return [] - - -@pytest.mark.unit -def test_sdk_bridge_native_command_candidates_collapse_grouped_commands() -> None: - bridge = SdkPluginBridge(_BridgeStarContext()) - bridge._records = { # noqa: SLF001 - "ai_girlfriend": SimpleNamespace( - plugin=SimpleNamespace( - name="ai_girlfriend", - manifest_data={"support_platforms": ["telegram", "discord"]}, - ), - load_order=0, - state="enabled", - handlers=[ - SimpleNamespace( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.chat", - trigger=CommandTrigger( - command="gf chat", - description="Switch to AI girlfriend persona", - ), - command_route=CommandRouteSpec( - group_path=["gf"], - display_command="gf chat", - group_help="AI girlfriend commands", - ), - ), - declaration_order=0, - ), - SimpleNamespace( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.affection", - trigger=CommandTrigger( - command="gf affection", - description="Show affection level", - ), - command_route=CommandRouteSpec( - group_path=["gf"], - display_command="gf affection", - group_help="AI girlfriend commands", - ), - ), - declaration_order=1, - ), - SimpleNamespace( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.discord_only", - trigger=CommandTrigger( - command="secret", - description="Discord only command", - ), - filters=[PlatformFilterSpec(platforms=["discord"])], - ), - declaration_order=2, - ), - ], - dynamic_command_routes=[], - session=None, - ) - } - - telegram_commands = bridge.list_native_command_candidates("telegram") - assert telegram_commands == [ - { - "name": "gf", - "description": "AI girlfriend commands", - "is_group": True, - } - ] - - discord_commands = bridge.list_native_command_candidates("discord") - assert discord_commands == [ - { - "name": "gf", - "description": "AI girlfriend commands", - "is_group": True, - }, - { - "name": "secret", - "description": "Discord only command", - "is_group": False, - }, - ] - - -@pytest.mark.unit -def test_telegram_collect_commands_includes_sdk_candidates( - mock_telegram_modules, # noqa: ARG001 - monkeypatch: pytest.MonkeyPatch, -) -> None: - sys.modules["telegram.ext"].ContextTypes.DEFAULT_TYPE = object - from astrbot.core.platform.sources.telegram import tg_adapter - - monkeypatch.setattr( - tg_adapter, - "BotCommand", - lambda command, description: SimpleNamespace( - command=command, - description=description, - ), - ) - - adapter = tg_adapter.TelegramPlatformAdapter( - {"telegram_token": "test-token", "id": "telegram-test"}, - {}, - asyncio.Queue(), - ) - adapter.sdk_plugin_bridge = SimpleNamespace( - list_native_command_candidates=lambda platform_name: ( - [ - { - "name": "gf", - "description": "AI girlfriend commands", - "is_group": True, - } - ] - if platform_name == "telegram" - else [] - ) - ) - - commands = adapter.collect_commands() - - assert [(item.command, item.description) for item in commands] == [ - ("gf", "AI girlfriend commands") - ] - - -@pytest.mark.unit -def test_discord_collect_commands_includes_sdk_candidates( - mock_discord_modules, # noqa: ARG001 -) -> None: - from astrbot.core.platform.sources.discord.discord_platform_adapter import ( - DiscordPlatformAdapter, - ) - - adapter = DiscordPlatformAdapter( - {"discord_token": "test-token", "id": "discord-test"}, - {}, - asyncio.Queue(), - ) - adapter.sdk_plugin_bridge = SimpleNamespace( - list_native_command_candidates=lambda platform_name: ( - [ - { - "name": "gf", - "description": "AI girlfriend commands", - "is_group": True, - } - ] - if platform_name == "discord" - else [] - ) - ) - - assert adapter.collect_commands() == [("gf", "AI girlfriend commands")] - - -@pytest.mark.unit -def test_sdk_bridge_dashboard_handler_items_use_real_descriptions_and_fallbacks() -> ( - None -): - bridge = SdkPluginBridge(_BridgeStarContext()) - - command_item = bridge._handler_to_dashboard_item( # noqa: SLF001 - SdkHandlerRef( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.chat", - trigger=CommandTrigger( - command="gf chat", - description="Switch to AI girlfriend persona", - ), - ), - declaration_order=0, - ) - ) - fallback_command_item = bridge._handler_to_dashboard_item( # noqa: SLF001 - SdkHandlerRef( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.mood", - trigger=CommandTrigger(command="gf mood"), - ), - declaration_order=1, - ) - ) - message_item = bridge._handler_to_dashboard_item( # noqa: SLF001 - SdkHandlerRef( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.memory", - trigger=MessageTrigger(keywords=["memory"]), - description="Capture structured memory hints", - ), - declaration_order=2, - ) - ) - event_item = bridge._handler_to_dashboard_item( # noqa: SLF001 - SdkHandlerRef( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.waiting", - trigger=EventTrigger(event_type="waiting_llm_request"), - ), - declaration_order=3, - ) - ) - schedule_item = bridge._handler_to_dashboard_item( # noqa: SLF001 - SdkHandlerRef( - descriptor=HandlerDescriptor( - id="ai_girlfriend:main.maintenance", - trigger=ScheduleTrigger(interval_seconds=60), - ), - declaration_order=4, - ) - ) - - assert command_item["event_type_h"] == "SDK 指令触发" - assert command_item["desc"] == "Switch to AI girlfriend persona" - assert command_item["type"] == "指令" - assert command_item["cmd"] == "gf chat" - - assert fallback_command_item["desc"] == "Command: gf mood" - - assert message_item["event_type_h"] == "SDK 消息触发" - assert message_item["desc"] == "Capture structured memory hints" - assert message_item["type"] == "关键词" - assert message_item["cmd"] == "memory" - - assert event_item["event_type_h"] == "SDK 事件触发" - assert event_item["desc"] == "无描述" - assert event_item["type"] == "事件" - assert event_item["cmd"] == "waiting_llm_request" - - assert schedule_item["event_type_h"] == "SDK 定时触发" - assert schedule_item["desc"] == "无描述" - assert schedule_item["type"] == "定时" - assert schedule_item["cmd"] == "60" - - -@pytest.mark.unit -def test_sdk_bridge_lists_dashboard_commands_and_tools(tmp_path) -> None: - bridge = SdkPluginBridge(_BridgeStarContext()) - bridge._records = { # noqa: SLF001 - "sdk-demo": SimpleNamespace( - plugin=SimpleNamespace( - name="sdk-demo", - plugin_dir=tmp_path / "sdk-demo", - manifest_data={"display_name": "SDK Demo"}, - ), - plugin_id="sdk-demo", - load_order=0, - state="enabled", - handlers=[ - SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.chat", - trigger=CommandTrigger( - command="gf chat", - description="Chat with the SDK plugin", - aliases=["girl chat"], - ), - command_route=CommandRouteSpec( - group_path=["gf"], - display_command="gf chat", - group_help="SDK group help", - ), - ), - declaration_order=0, - ), - SimpleNamespace( - descriptor=HandlerDescriptor( - id="sdk-demo:main.ping", - trigger=CommandTrigger(command="ping"), - ), - declaration_order=1, - ), - ], - llm_tools={ - "memory.search": LLMToolSpec.create( - name="memory.search", - description="Search SDK memory", - parameters_schema={"type": "object", "properties": {}}, - active=True, - ) - }, - dynamic_command_routes=[], - session=None, - ) - } - - commands = bridge.list_dashboard_commands() - tools = bridge.list_dashboard_tools() - - group = next(item for item in commands if item["type"] == "group") - assert group["command_key"] == "sdk:group:sdk-demo:gf" - assert group["effective_command"] == "gf" - assert group["description"] == "SDK group help" - assert group["sub_commands"][0]["effective_command"] == "gf chat" - assert group["sub_commands"][0]["aliases"] == ["girl chat"] - - root_command = next( - item for item in commands if item["effective_command"] == "ping" - ) - assert root_command["command_key"] == "sdk:command:sdk-demo:sdk-demo:main.ping" - assert root_command["runtime_kind"] == "sdk" - assert root_command["supports_toggle"] is False - - assert tools == [ - { - "tool_key": "sdk:sdk-demo:memory.search", - "name": "memory.search", - "description": "Search SDK memory", - "parameters": {"type": "object", "properties": {}}, - "active": True, - "origin": "sdk_plugin", - "origin_name": "SDK Demo", - "runtime_kind": "sdk", - "plugin_id": "sdk-demo", - } - ] diff --git a/tests/test_sdk/unit/test_sdk_persona_conversation_kb_managers.py b/tests/test_sdk/unit/test_sdk_persona_conversation_kb_managers.py deleted file mode 100644 index b26dcbee8..000000000 --- a/tests/test_sdk/unit/test_sdk_persona_conversation_kb_managers.py +++ /dev/null @@ -1,599 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import sys -import types -from dataclasses import dataclass -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - install( - "aiocqhttp", - { - "CQHttp": type("CQHttp", (), {}), - "Event": type("Event", (), {}), - }, - ) - install( - "aiocqhttp.exceptions", - {"ActionFailed": type("ActionFailed", (Exception,), {})}, - ) - - -_install_optional_dependency_stubs() - -from astrbot_sdk import MessageSession -from astrbot_sdk.clients.managers import ( - ConversationCreateParams, - ConversationRecord, - ConversationUpdateParams, - KnowledgeBaseCreateParams, - KnowledgeBaseDocumentUploadParams, - KnowledgeBaseUpdateParams, - PersonaCreateParams, - PersonaUpdateParams, -) -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.testing import MockContext - -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_manager_clients_round_trip(tmp_path: Path) -> None: - ctx = MockContext(plugin_id="sdk-demo") - - assert ctx.persona_manager is ctx.personas - assert ctx.conversation_manager is ctx.conversations - assert ctx.kb_manager is ctx.kbs - - persona = await ctx.personas.create_persona( - PersonaCreateParams( - persona_id="helper", - system_prompt="Be helpful", - begin_dialogs=["user hello", "assistant hello"], - tools=["tool-a"], - custom_error_message="fallback", - sort_order=3, - ) - ) - assert persona.persona_id == "helper" - assert persona.tools == ["tool-a"] - assert (await ctx.personas.get_persona("helper")).system_prompt == "Be helpful" - updated_persona = await ctx.personas.update_persona( - "helper", - PersonaUpdateParams( - system_prompt="Be precise", - tools=None, - custom_error_message=None, - ), - ) - assert updated_persona is not None - assert updated_persona.system_prompt == "Be precise" - assert updated_persona.tools is None - assert updated_persona.custom_error_message is None - assert [item.persona_id for item in await ctx.personas.get_all_personas()] == [ - "helper" - ] - await ctx.personas.delete_persona("helper") - with pytest.raises(Exception): - await ctx.personas.get_persona("helper") - - session = MessageSession( - platform_id="demo-platform", - message_type="private", - session_id="user-1", - ) - conversation_a = await ctx.conversations.new_conversation( - session, - ConversationCreateParams( - title="first", - history=[{"role": "user", "content": "hello"}], - ), - ) - conversation_b = await ctx.conversations.new_conversation( - str(session), - ConversationCreateParams( - title="second", - persona_id="persona-2", - ), - ) - await ctx.conversations.switch_conversation(session, conversation_a) - await ctx.conversations.delete_conversation(session, None) - - assert await ctx.conversations.get_conversation(session, conversation_a) is None - remaining_conversations = await ctx.conversations.get_conversations(session) - assert [item.conversation_id for item in remaining_conversations] == [ - conversation_b - ] - - await ctx.conversations.update_conversation( - session, - None, - ConversationUpdateParams( - title="second-updated", - token_usage=42, - history=[{"role": "assistant", "content": "updated"}], - ), - ) - current_conversation = await ctx.conversations.get_conversation( - session, - conversation_b, - ) - assert isinstance(current_conversation, ConversationRecord) - assert current_conversation.title == "second-updated" - assert current_conversation.token_usage == 42 - assert current_conversation.history == [{"role": "assistant", "content": "updated"}] - - await ctx.conversations.unset_persona(session, conversation_b) - current_conversation = await ctx.conversations.get_conversation( - session, - conversation_b, - ) - assert isinstance(current_conversation, ConversationRecord) - assert current_conversation.persona_id is None - - kb = await ctx.kbs.create_kb( - KnowledgeBaseCreateParams( - kb_name="Demo KB", - embedding_provider_id="mock-embedding-provider", - top_k_dense=5, - ) - ) - assert kb.kb_name == "Demo KB" - assert kb.embedding_provider_id == "mock-embedding-provider" - listed_kbs = await ctx.kbs.list_kbs() - assert [item.kb_id for item in listed_kbs] == [kb.kb_id] - - updated_kb = await ctx.kbs.update_kb( - kb.kb_id, - KnowledgeBaseUpdateParams(description="Updated KB", top_m_final=3), - ) - assert updated_kb is not None - assert updated_kb.description == "Updated KB" - assert updated_kb.top_m_final == 3 - - document_path = tmp_path / "kb-note.txt" - document_path.write_text("AstrBot SDK knowledge base note", encoding="utf-8") - document_token = await ctx.files.register_file(str(document_path)) - document = await ctx.kbs.upload_document( - kb.kb_id, - KnowledgeBaseDocumentUploadParams(file_token=document_token), - ) - assert document.kb_id == kb.kb_id - assert document.doc_name == "kb-note.txt" - - listed_documents = await ctx.kbs.list_documents(kb.kb_id) - assert [item.doc_id for item in listed_documents] == [document.doc_id] - assert (await ctx.kbs.get_document(kb.kb_id, document.doc_id)) is not None - - retrieved = await ctx.kbs.retrieve( - "AstrBot knowledge", - kb_ids=[kb.kb_id], - top_m_final=1, - ) - assert retrieved is not None - assert [item.doc_id for item in retrieved.results] == [document.doc_id] - - refreshed_document = await ctx.kbs.refresh_document(kb.kb_id, document.doc_id) - assert refreshed_document is not None - assert refreshed_document.doc_id == document.doc_id - - assert await ctx.kbs.delete_document(kb.kb_id, document.doc_id) is True - assert await ctx.kbs.get_document(kb.kb_id, document.doc_id) is None - assert (await ctx.kbs.get_kb(kb.kb_id)) is not None - assert await ctx.kbs.delete_kb(kb.kb_id) is True - assert await ctx.kbs.get_kb(kb.kb_id) is None - - with pytest.raises(Exception): - KnowledgeBaseCreateParams.model_validate({"kb_name": "Missing embedding"}) - - -@dataclass(slots=True) -class _FakeKBRecord: - kb_id: str = "kb-1" - kb_name: str = "Demo KB" - description: str | None = "desc" - emoji: str | None = "📚" - embedding_provider_id: str = "embedding-1" - rerank_provider_id: str | None = "rerank-1" - chunk_size: int | None = 512 - chunk_overlap: int | None = 32 - top_k_dense: int | None = 8 - top_k_sparse: int | None = 10 - top_m_final: int | None = 5 - doc_count: int = 2 - chunk_count: int = 8 - created_at: object | None = None - updated_at: object | None = None - - -@dataclass(slots=True) -class _FakeKBDocumentRecord: - doc_id: str = "doc-1" - kb_id: str = "kb-1" - doc_name: str = "Guide.txt" - file_type: str = "txt" - file_size: int = 17 - file_path: str = "" - chunk_count: int = 1 - media_count: int = 0 - created_at: object | None = None - updated_at: object | None = None - - -class _FakeKBHelper: - def __init__(self, kb: _FakeKBRecord) -> None: - self.kb = kb - self.documents: dict[str, _FakeKBDocumentRecord] = { - "doc-1": _FakeKBDocumentRecord(kb_id=kb.kb_id) - } - self.upload_calls: list[dict[str, object | None]] = [] - self.deleted_document_ids: list[str] = [] - self.refreshed_document_ids: list[str] = [] - - async def list_documents( - self, - offset: int = 0, - limit: int = 100, - ) -> list[_FakeKBDocumentRecord]: - return list(self.documents.values())[offset : offset + limit] - - async def get_document(self, doc_id: str) -> _FakeKBDocumentRecord | None: - return self.documents.get(doc_id) - - async def upload_document(self, **kwargs) -> _FakeKBDocumentRecord: - self.upload_calls.append(dict(kwargs)) - document = _FakeKBDocumentRecord( - doc_id="doc-uploaded", - kb_id=self.kb.kb_id, - doc_name=str(kwargs.get("file_name", "Uploaded.txt")), - file_type=str(kwargs.get("file_type", "txt")), - file_size=( - len(kwargs["file_content"]) - if isinstance(kwargs.get("file_content"), bytes) - else len("".join(kwargs.get("pre_chunked_text") or [])) - ), - ) - self.documents[document.doc_id] = document - self.kb.doc_count = len(self.documents) - self.kb.chunk_count = sum(item.chunk_count for item in self.documents.values()) - return document - - async def delete_document(self, doc_id: str) -> None: - self.deleted_document_ids.append(doc_id) - self.documents.pop(doc_id, None) - self.kb.doc_count = len(self.documents) - self.kb.chunk_count = sum(item.chunk_count for item in self.documents.values()) - - async def refresh_document(self, doc_id: str) -> None: - self.refreshed_document_ids.append(doc_id) - - -class _FakeConversationManager: - def __init__(self) -> None: - self.delete_calls: list[tuple[str, str | None]] = [] - - async def new_conversation(self, *args, **kwargs) -> str: # pragma: no cover - return "conv-created" - - async def switch_conversation(self, *args, **kwargs) -> None: # pragma: no cover - return None - - async def delete_conversation( - self, - unified_msg_origin: str, - conversation_id: str | None = None, - ) -> None: - self.delete_calls.append((unified_msg_origin, conversation_id)) - - async def get_conversation(self, *args, **kwargs): # pragma: no cover - return None - - async def get_conversations( - self, *args, **kwargs - ) -> list[object]: # pragma: no cover - return [] - - async def update_conversation(self, *args, **kwargs) -> None: # pragma: no cover - return None - - -class _FakePersonaManager: - async def get_persona(self, persona_id: str): # pragma: no cover - raise ValueError(f"Persona with ID {persona_id} does not exist.") - - async def get_all_personas(self) -> list[object]: # pragma: no cover - return [] - - async def create_persona(self, **kwargs): # pragma: no cover - return None - - async def update_persona(self, **kwargs): # pragma: no cover - return None - - async def delete_persona(self, persona_id: str) -> None: # pragma: no cover - return None - - -class _FakeKnowledgeBaseManager: - def __init__(self) -> None: - self.deleted_ids: list[str] = [] - self.created_payloads: list[dict[str, object | None]] = [] - self.updated_payloads: list[dict[str, object | None]] = [] - self.retrieve_calls: list[dict[str, object | None]] = [] - self.upload_from_url_calls: list[dict[str, object | None]] = [] - self.helper = _FakeKBHelper(_FakeKBRecord()) - - async def get_kb(self, kb_id: str): - if kb_id != self.helper.kb.kb_id: - return None - return self.helper - - async def create_kb(self, **kwargs): - self.created_payloads.append(dict(kwargs)) - self.helper = _FakeKBHelper(_FakeKBRecord(kb_id="kb-created", kb_name="Created KB")) - return self.helper - - async def delete_kb(self, kb_id: str) -> bool: - self.deleted_ids.append(kb_id) - return kb_id == "kb-1" - - async def list_kbs(self) -> list[_FakeKBRecord]: - return [self.helper.kb] - - async def update_kb(self, **kwargs): - self.updated_payloads.append(dict(kwargs)) - kb_name = kwargs.get("kb_name") - if kb_name is not None: - self.helper.kb.kb_name = str(kb_name) - if "description" in kwargs and kwargs["description"] is not None: - self.helper.kb.description = str(kwargs["description"]) - if "top_m_final" in kwargs and kwargs["top_m_final"] is not None: - self.helper.kb.top_m_final = int(kwargs["top_m_final"]) - return self.helper - - async def retrieve( - self, - *, - query: str, - kb_names: list[str], - top_k_fusion: int = 20, - top_m_final: int = 5, - ) -> dict[str, object] | None: - self.retrieve_calls.append( - { - "query": query, - "kb_names": list(kb_names), - "top_k_fusion": top_k_fusion, - "top_m_final": top_m_final, - } - ) - if not kb_names: - return None - return { - "context_text": "Mock KB context", - "results": [ - { - "chunk_id": "chunk-1", - "doc_id": "doc-1", - "kb_id": self.helper.kb.kb_id, - "kb_name": self.helper.kb.kb_name, - "doc_name": "Guide.txt", - "chunk_index": 0, - "content": "AstrBot KB guide", - "score": 0.9, - "char_count": 16, - } - ], - } - - async def upload_from_url(self, **kwargs) -> _FakeKBDocumentRecord: - self.upload_from_url_calls.append(dict(kwargs)) - document = _FakeKBDocumentRecord( - doc_id="doc-from-url", - kb_id=str(kwargs.get("kb_id", self.helper.kb.kb_id)), - doc_name="from-url.url", - file_type="url", - ) - self.helper.documents[document.doc_id] = document - return document - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_bridge_serializes_kb_record_and_preserves_delete_none_semantics() -> ( - None -): - fake_conversation_manager = _FakeConversationManager() - fake_kb_manager = _FakeKnowledgeBaseManager() - bridge = CoreCapabilityBridge( - star_context=SimpleNamespace( - persona_manager=_FakePersonaManager(), - conversation_manager=fake_conversation_manager, - kb_manager=fake_kb_manager, - ), - plugin_bridge=SimpleNamespace(resolve_request_session=lambda _request_id: None), - ) - assert "persona.get" in {item.name for item in bridge.descriptors()} - assert "conversation.new" in {item.name for item in bridge.descriptors()} - assert "kb.get" in {item.name for item in bridge.descriptors()} - assert "kb.list" in {item.name for item in bridge.descriptors()} - assert "kb.retrieve" in {item.name for item in bridge.descriptors()} - assert "kb.document.upload" in {item.name for item in bridge.descriptors()} - - await bridge._conversation_delete( - "req-1", - {"session": "demo-platform:private:user-1", "conversation_id": None}, - None, - ) - assert fake_conversation_manager.delete_calls == [ - ("demo-platform:private:user-1", None) - ] - - kb_get = await bridge._kb_get("req-2", {"kb_id": "kb-1"}, None) - assert kb_get["kb"] is not None - assert kb_get["kb"]["kb_id"] == "kb-1" - assert kb_get["kb"]["kb_name"] == "Demo KB" - assert kb_get["kb"]["embedding_provider_id"] == "embedding-1" - - kb_list = await bridge._kb_list("req-2b", {}, None) - assert [item["kb_id"] for item in kb_list["kbs"]] == ["kb-1"] - - kb_create = await bridge._kb_create( - "req-3", - { - "kb": { - "kb_name": "Created KB", - "embedding_provider_id": "embedding-1", - } - }, - None, - ) - assert kb_create["kb"]["kb_id"] == "kb-created" - assert fake_kb_manager.created_payloads == [ - { - "kb_name": "Created KB", - "description": None, - "emoji": None, - "embedding_provider_id": "embedding-1", - "rerank_provider_id": None, - "chunk_size": None, - "chunk_overlap": None, - "top_k_dense": None, - "top_k_sparse": None, - "top_m_final": None, - } - ] - - kb_update = await bridge._kb_update( - "req-3b", - {"kb_id": "kb-created", "kb": {"description": "Updated", "top_m_final": 2}}, - None, - ) - assert kb_update["kb"] is not None - assert kb_update["kb"]["description"] == "Updated" - assert fake_kb_manager.updated_payloads[-1]["top_m_final"] == 2 - - kb_delete = await bridge._kb_delete("req-4", {"kb_id": "kb-1"}, None) - assert kb_delete == {"deleted": True} - assert fake_kb_manager.deleted_ids == ["kb-1"] - - kb_retrieve = await bridge._kb_retrieve( - "req-5", - {"query": "AstrBot", "kb_ids": ["kb-created"], "top_m_final": 1}, - None, - ) - assert kb_retrieve["result"] is not None - assert kb_retrieve["result"]["results"][0]["doc_id"] == "doc-1" - assert fake_kb_manager.retrieve_calls[-1]["kb_names"] == ["Created KB"] - - kb_document_list = await bridge._kb_document_list( - "req-6", - {"kb_id": "kb-created"}, - None, - ) - assert [item["doc_id"] for item in kb_document_list["documents"]] == ["doc-1"] - - kb_document_get = await bridge._kb_document_get( - "req-7", - {"kb_id": "kb-created", "doc_id": "doc-1"}, - None, - ) - assert kb_document_get["document"] is not None - assert kb_document_get["document"]["doc_name"] == "Guide.txt" - - kb_document_upload = await bridge._kb_document_upload( - "req-7b", - { - "kb_id": "kb-created", - "document": {"text": "inline knowledge", "file_name": "inline.txt"}, - }, - None, - ) - assert kb_document_upload["document"] is not None - assert kb_document_upload["document"]["doc_name"] == "inline.txt" - assert fake_kb_manager.helper.upload_calls[-1]["pre_chunked_text"] == [ - "inline knowledge" - ] - - kb_document_refresh = await bridge._kb_document_refresh( - "req-8", - {"kb_id": "kb-created", "doc_id": "doc-1"}, - None, - ) - assert kb_document_refresh["document"] is not None - assert fake_kb_manager.helper.refreshed_document_ids == ["doc-1"] - - kb_document_delete = await bridge._kb_document_delete( - "req-9", - {"kb_id": "kb-created", "doc_id": "doc-1"}, - None, - ) - assert kb_document_delete == {"deleted": True} - assert fake_kb_manager.helper.deleted_document_ids == ["doc-1"] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_bridge_validates_conversation_session_inputs() -> None: - bridge = CoreCapabilityBridge( - star_context=SimpleNamespace( - persona_manager=_FakePersonaManager(), - conversation_manager=_FakeConversationManager(), - kb_manager=_FakeKnowledgeBaseManager(), - ), - plugin_bridge=SimpleNamespace(resolve_request_session=lambda _request_id: None), - ) - - with pytest.raises(AstrBotError, match="conversation.new requires session"): - await bridge._conversation_new("req-1", {"session": " "}, None) - - with pytest.raises(AstrBotError, match="conversation.switch requires session"): - await bridge._conversation_switch( - "req-2", - {"session": " ", "conversation_id": "conv-1"}, - None, - ) - - with pytest.raises( - AstrBotError, - match="conversation.switch requires conversation_id", - ): - await bridge._conversation_switch( - "req-3", - {"session": "demo-platform:private:user-1", "conversation_id": " "}, - None, - ) diff --git a/tests/test_sdk/unit/test_sdk_plugin_config_bridge.py b/tests/test_sdk/unit/test_sdk_plugin_config_bridge.py deleted file mode 100644 index 6fa3a74d3..000000000 --- a/tests/test_sdk/unit/test_sdk_plugin_config_bridge.py +++ /dev/null @@ -1,152 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import logging -from pathlib import Path -from types import SimpleNamespace -from typing import Any, cast - -import pytest -from astrbot_sdk.runtime.loader import ( - PluginSpec, - load_plugin_config_schema, -) -from quart import Quart - -from astrbot.dashboard.routes.config import ConfigRoute -from astrbot.dashboard.routes.route import RouteContext - - -class _FakePluginManager: - def __init__(self) -> None: - self.reloaded: list[str] = [] - - async def reload(self, plugin_name: str | None = None) -> tuple[bool, str]: - self.reloaded.append(str(plugin_name)) - return True, "" - - -class _FakeSdkBridge: - def __init__(self) -> None: - self.schemas: dict[str, dict[str, Any]] = { - "sdk-demo": { - "count": { - "type": "int", - "description": "counter", - "default": 1, - } - } - } - self.configs: dict[str, dict[str, Any]] = {"sdk-demo": {"count": 1}} - self.saved: list[tuple[str, dict[str, Any]]] = [] - self.reloaded: list[str] = [] - - def get_plugin_metadata(self, plugin_name: str) -> dict[str, Any] | None: - if plugin_name not in self.schemas: - return None - return {"name": plugin_name, "runtime_kind": "sdk"} - - def get_plugin_config_schema(self, plugin_name: str) -> dict[str, Any] | None: - schema = self.schemas.get(plugin_name) - return dict(schema) if schema is not None else None - - def get_plugin_config(self, plugin_name: str) -> dict[str, Any] | None: - config = self.configs.get(plugin_name) - return dict(config) if config is not None else None - - def save_plugin_config( - self, - plugin_name: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - saved = dict(payload) - self.configs[plugin_name] = saved - self.saved.append((plugin_name, saved)) - return dict(saved) - - async def reload_plugin(self, plugin_name: str) -> None: - self.reloaded.append(plugin_name) - - -def _build_config_route( - *, - sdk_bridge: _FakeSdkBridge | None = None, -) -> tuple[ConfigRoute, Quart, _FakePluginManager]: - app = Quart(__name__) - plugin_manager = _FakePluginManager() - core_lifecycle = SimpleNamespace( - astrbot_config=cast(Any, {}), - astrbot_config_mgr=SimpleNamespace(confs={}), - plugin_manager=plugin_manager, - sdk_plugin_bridge=sdk_bridge, - umop_config_router=SimpleNamespace(), - ) - route = ConfigRoute( - RouteContext(config=cast(Any, {}), app=app), - core_lifecycle=cast(Any, core_lifecycle), - ) - return route, app, plugin_manager - - -@pytest.mark.unit -def test_load_plugin_config_schema_logs_invalid_json( - tmp_path: Path, - caplog: pytest.LogCaptureFixture, -) -> None: - plugin_dir = tmp_path / "sdk_demo" - plugin_dir.mkdir() - schema_path = plugin_dir / "_conf_schema.json" - schema_path.write_text('{"count": {"type": "int",},}', encoding="utf-8") - - plugin = PluginSpec( - name="sdk-demo", - plugin_dir=plugin_dir, - manifest_path=plugin_dir / "plugin.yaml", - requirements_path=plugin_dir / "requirements.txt", - python_version="3.11", - manifest_data={}, - ) - - with caplog.at_level(logging.WARNING): - schema = load_plugin_config_schema(plugin) - - assert schema == {} - assert "Failed to parse SDK plugin config schema" in caplog.text - assert str(schema_path) in caplog.text - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_config_route_get_plugin_config_supports_sdk_bridge() -> None: - sdk_bridge = _FakeSdkBridge() - route, _, _ = _build_config_route(sdk_bridge=sdk_bridge) - - result = await route._get_plugin_config("sdk-demo") - - assert result["config"] == {"count": 1} - assert result["metadata"] == { - "sdk-demo": { - "description": "sdk-demo 配置", - "type": "object", - "items": sdk_bridge.schemas["sdk-demo"], - } - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_post_plugin_configs_saves_and_reloads_sdk_plugin() -> None: - sdk_bridge = _FakeSdkBridge() - route, app, plugin_manager = _build_config_route(sdk_bridge=sdk_bridge) - - async with app.test_request_context( - "/api/config/plugin/update?plugin_name=sdk-demo", - method="POST", - json={"count": "2"}, - ): - response = await route.post_plugin_configs() - - assert response["status"] == "ok" - assert sdk_bridge.saved == [("sdk-demo", {"count": 2})] - assert sdk_bridge.reloaded == ["sdk-demo"] - assert plugin_manager.reloaded == [] diff --git a/tests/test_sdk/unit/test_sdk_provider_platform_management.py b/tests/test_sdk/unit/test_sdk_provider_platform_management.py deleted file mode 100644 index b0529e1b1..000000000 --- a/tests/test_sdk/unit/test_sdk_provider_platform_management.py +++ /dev/null @@ -1,698 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import asyncio -import sys -import types -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime, timezone -from types import SimpleNamespace -from typing import Any, cast - -import pytest - - -def _install_optional_dependency_stubs() -> None: - def install(name: str, attrs: dict[str, object]) -> None: - if name in sys.modules: - return - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - - install( - "faiss", - { - "read_index": lambda *args, **kwargs: None, - "write_index": lambda *args, **kwargs: None, - "IndexFlatL2": type("IndexFlatL2", (), {}), - "IndexIDMap": type("IndexIDMap", (), {}), - "normalize_L2": lambda *args, **kwargs: None, - }, - ) - install("pypdf", {"PdfReader": type("PdfReader", (), {})}) - install( - "jieba", - { - "cut": lambda text, *args, **kwargs: text.split(), - "lcut": lambda text, *args, **kwargs: text.split(), - }, - ) - install("rank_bm25", {"BM25Okapi": type("BM25Okapi", (), {})}) - install( - "aiocqhttp", - { - "CQHttp": type("CQHttp", (), {}), - "Event": type("Event", (), {}), - }, - ) - install( - "aiocqhttp.exceptions", - {"ActionFailed": type("ActionFailed", (Exception,), {})}, - ) - - -_install_optional_dependency_stubs() - -from astrbot_sdk import PlatformStatus -from astrbot_sdk._internal.invocation_context import caller_plugin_scope -from astrbot_sdk.clients.provider import ProviderManagerClient -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.llm.entities import ProviderType -from astrbot_sdk.testing import MockContext - -from astrbot.core.sdk_bridge.capability_bridge import CoreCapabilityBridge - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_merged_provider_config_is_reserved_only() -> None: - ordinary_ctx = MockContext(plugin_id="plain-plugin") - with caller_plugin_scope("plain-plugin"): - with pytest.raises(AstrBotError, match="reserved/system"): - await ordinary_ctx.mock_peer.invoke( - "provider.manager.get_merged_provider_config", - {"provider_id": "mock-chat-provider"}, - ) - - ctx = MockContext( - plugin_id="reserved-plugin", - plugin_metadata={"reserved": True}, - ) - assert ( - "provider.manager.get_merged_provider_config" - in ctx.mock_peer.remote_capability_map - ) - with caller_plugin_scope("reserved-plugin"): - output = await ctx.mock_peer.invoke( - "provider.manager.get_merged_provider_config", - {"provider_id": "mock-chat-provider"}, - ) - assert output["config"]["id"] == "mock-chat-provider" - assert output["config"]["provider_type"] == "chat_completion" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_merged_provider_config_keeps_nested_payload() -> None: - ctx = MockContext( - plugin_id="reserved-plugin", - plugin_metadata={"reserved": True}, - ) - nested_config = { - "id": "mock-chat-provider", - "type": "mock", - "provider_type": "chat_completion", - "enable": True, - "provider_settings": { - "headers": {"x-trace-id": "trace-1"}, - "options": {"temperature": 0.2, "top_p": 0.9}, - }, - } - ctx.router._provider_configs["mock-chat-provider"] = dict(nested_config) - - with caller_plugin_scope("reserved-plugin"): - output = await ctx.mock_peer.invoke( - "provider.manager.get_merged_provider_config", - {"provider_id": "mock-chat-provider"}, - ) - - assert output["config"]["provider_settings"] == nested_config["provider_settings"] - assert output["config"]["enable"] is True - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_provider_client_get_merged_provider_config_returns_plain_dict_copy() -> ( - None -): - ctx = MockContext( - plugin_id="reserved-plugin", - plugin_metadata={"reserved": True}, - ) - nested_config = { - "id": "mock-chat-provider", - "type": "mock", - "provider_type": "chat_completion", - "enable": True, - "provider_settings": { - "headers": {"x-trace-id": "trace-2"}, - "options": {"temperature": 0.3}, - }, - } - ctx.router._provider_configs["mock-chat-provider"] = dict(nested_config) - - output = await ctx.provider_manager.get_merged_provider_config("mock-chat-provider") - - assert output is not None - assert output["id"] == nested_config["id"] - assert output["type"] == nested_config["type"] - assert output["provider_type"] == nested_config["provider_type"] - assert output["enable"] is True - assert output["provider_settings"] == nested_config["provider_settings"] - assert output is not ctx.router._provider_configs["mock-chat-provider"] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_provider_client_get_merged_provider_config_strips_provider_id() -> None: - class _RecordingProxy: - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any]]] = [] - - async def call( - self, - capability: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - self.calls.append((capability, dict(payload))) - return {"config": {"id": "mock-chat-provider"}} - - proxy = _RecordingProxy() - client = ProviderManagerClient(cast(Any, proxy)) - - output = await client.get_merged_provider_config(" mock-chat-provider ") - - assert output == {"id": "mock-chat-provider"} - assert proxy.calls == [ - ( - "provider.manager.get_merged_provider_config", - {"provider_id": "mock-chat-provider"}, - ) - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_provider_client_get_merged_provider_config_rejects_non_reserved_plugin() -> ( - None -): - ctx = MockContext(plugin_id="plain-plugin") - - with pytest.raises(AstrBotError, match="reserved/system"): - await ctx.provider_manager.get_merged_provider_config("mock-chat-provider") - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_provider_management_is_reserved_only() -> None: - ordinary_ctx = MockContext(plugin_id="plain-plugin") - with pytest.raises(AstrBotError, match="reserved/system"): - await ordinary_ctx.provider_manager.get_insts() - - ctx = MockContext( - plugin_id="reserved-plugin", - plugin_metadata={"reserved": True}, - ) - insts = await ctx.provider_manager.get_insts() - assert [item.id for item in insts] == ["mock-chat-provider"] - - stream = ctx.provider_manager.watch_changes() - - async def _next_provider_change() -> Any: - return await anext(stream) - - waiter = asyncio.create_task(_next_provider_change()) - await asyncio.sleep(0) - await ctx.provider_manager.set_provider( - "mock-chat-provider", - ProviderType.CHAT_COMPLETION, - umo="demo-session", - ) - event = await asyncio.wait_for(waiter, timeout=1) - assert event.provider_id == "mock-chat-provider" - assert event.provider_type == ProviderType.CHAT_COMPLETION - assert event.umo == "demo-session" - await cast(Any, stream).aclose() - - callback_ready = asyncio.Event() - seen: list[tuple[str, ProviderType, str | None]] = [] - - async def on_change( - provider_id: str, - provider_type: ProviderType, - umo: str | None, - ) -> None: - seen.append((provider_id, provider_type, umo)) - callback_ready.set() - - task = await ctx.provider_manager.register_provider_change_hook(on_change) - await asyncio.sleep(0) - ctx.router.emit_provider_change( - "mock-chat-provider", - ProviderType.CHAT_COMPLETION.value, - "umo-2", - ) - await asyncio.wait_for(callback_ready.wait(), timeout=1) - assert seen == [("mock-chat-provider", ProviderType.CHAT_COMPLETION, "umo-2")] - await ctx.provider_manager.unregister_provider_change_hook(task) - assert task.done() - callback_ready.clear() - ctx.router.emit_provider_change( - "mock-chat-provider", - ProviderType.CHAT_COMPLETION.value, - "umo-3", - ) - await asyncio.sleep(0.05) - assert seen == [("mock-chat-provider", ProviderType.CHAT_COMPLETION, "umo-2")] - assert callback_ready.is_set() is False - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_platform_facade_refresh_and_clear_errors() -> None: - ordinary_ctx = MockContext(plugin_id="plain-plugin") - ordinary_platform = await ordinary_ctx.get_platform_inst("mock-platform") - assert ordinary_platform is not None - with pytest.raises(AstrBotError, match="reserved/system"): - await ordinary_platform.refresh() - - ctx = MockContext( - plugin_id="reserved-plugin", - plugin_metadata={"reserved": True}, - ) - error_payload = { - "message": "boom", - "timestamp": "2026-03-16T00:00:00+00:00", - "traceback": "traceback", - } - ctx.router.set_platform_instances([ - { - "id": "mock-platform", - "name": "Mock Platform", - "type": "mock", - "status": "error", - "errors": [error_payload], - "last_error": error_payload, - "unified_webhook": True, - "stats": { - "id": "mock-platform", - "type": "mock", - "display_name": "Mock Platform", - "status": "error", - "started_at": None, - "error_count": 1, - "last_error": error_payload, - "unified_webhook": True, - "meta": {"support_streaming_message": True}, - }, - } - ]) - - platform = await ctx.get_platform_inst("mock-platform") - assert platform is not None - assert platform.status == PlatformStatus.ERROR - await platform.refresh() - assert platform.unified_webhook is True - assert platform.last_error is not None - assert platform.last_error.message == "boom" - await asyncio.gather(platform.refresh(), platform.refresh()) - stats = await platform.get_stats() - assert stats is not None - assert stats.status == PlatformStatus.ERROR - assert stats.error_count == 1 - await platform.clear_errors() - assert platform.status == PlatformStatus.RUNNING - assert platform.errors == [] - assert platform.last_error is None - - -@dataclass(slots=True) -class _FakeProviderMeta: - id: str - model: str | None - type: str - provider_type: object - - -class _FakeProvider: - def __init__( - self, provider_id: str, provider_type: str, model: str = "demo" - ) -> None: - self.provider_config = { - "id": provider_id, - "type": "mock", - "provider_type": provider_type, - "enable": True, - } - self._meta = _FakeProviderMeta( - id=provider_id, - model=model, - type="mock", - provider_type=provider_type, - ) - - def meta(self) -> _FakeProviderMeta: - return self._meta - - -class _FakeProviderManager: - def __init__(self) -> None: - self.providers_config = [ - { - "id": "chat-main", - "type": "mock", - "provider_type": "chat_completion", - "enable": True, - "provider_source_id": "shared-source", - "model_config": {"temperature": 0.6}, - "provider_settings": { - "headers": {"x-provider": "chat-main"}, - "options": { - "temperature": 0.6, - "penalties": {"frequency": 0.1}, - }, - }, - }, - { - "id": "chat-disabled", - "type": "mock", - "provider_type": "chat_completion", - "enable": False, - }, - ] - self.provider_sources_config = [ - { - "id": "shared-source", - "endpoint": "https://example.invalid/v1", - "headers": {"x-source": "provider-source"}, - "model_config": {"temperature": 0.3, "top_p": 0.95}, - "source_metadata": { - "routing": { - "primary": "edge-a", - "fallbacks": ["edge-b", "edge-c"], - } - }, - } - ] - self.inst_map = {"chat-main": _FakeProvider("chat-main", "chat_completion")} - self.provider_insts = [self.inst_map["chat-main"]] - self._hooks: list[Callable[[str, str, str | None], object]] = [] - - def get_insts(self) -> list[object]: - return list(self.provider_insts) - - def register_provider_change_hook( - self, hook: Callable[[str, str, str | None], object] - ) -> None: - self._hooks.append(hook) - - def unregister_provider_change_hook( - self, hook: Callable[[str, str, str | None], object] - ) -> None: - if hook in self._hooks: - self._hooks.remove(hook) - - def fire_change( - self, provider_id: str, provider_type: str, umo: str | None - ) -> None: - for hook in list(self._hooks): - hook(provider_id, provider_type, umo) - - def get_merged_provider_config( - self, provider_config: dict[str, object] - ) -> dict[str, object]: - merged = dict(provider_config) - provider_source_id = merged.get("provider_source_id") - if not isinstance(provider_source_id, str) or not provider_source_id: - return merged - provider_source = next( - ( - item - for item in self.provider_sources_config - if item.get("id") == provider_source_id - ), - None, - ) - if not isinstance(provider_source, dict): - return merged - merged = {**provider_source, **merged} - merged["id"] = str(provider_config["id"]) - return merged - - -@dataclass(slots=True) -class _FakePlatformError: - message: str - timestamp: datetime - traceback: str | None = None - - -class _FakePlatform: - def __init__(self) -> None: - self._meta = SimpleNamespace( - id="demo-platform", - name="mock", - adapter_display_name="Demo Platform", - ) - self.status = SimpleNamespace(value="error") - self.errors = [ - _FakePlatformError( - message="broken", - timestamp=datetime(2026, 3, 16, tzinfo=timezone.utc), - traceback="trace", - ) - ] - self.last_error = self.errors[-1] - self._stats = { - "id": "demo-platform", - "type": "mock", - "display_name": "Demo Platform", - "status": "error", - "started_at": None, - "error_count": 1, - "last_error": { - "message": "broken", - "timestamp": "2026-03-16T00:00:00+00:00", - "traceback": "trace", - }, - "unified_webhook": True, - "meta": {"support_streaming_message": True}, - } - - def meta(self): - return self._meta - - def unified_webhook(self) -> bool: - return True - - def clear_errors(self) -> None: - self.errors = [] - self.last_error = None - self.status = SimpleNamespace(value="running") - self._stats["status"] = "running" - self._stats["error_count"] = 0 - self._stats["last_error"] = None - - def get_stats(self) -> dict[str, object]: - return dict(self._stats) - - -class _FakePluginBridge: - def __init__(self) -> None: - self._plugin_ids = { - "reserved-request": "reserved-plugin", - "plain-request": "plain-plugin", - } - - def resolve_request_session(self, _request_id: str): - return None - - def resolve_request_plugin_id(self, request_id: str) -> str: - return self._plugin_ids[request_id] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_reserved_gate_and_stream_cleanup() -> None: - provider_manager = _FakeProviderManager() - platform = _FakePlatform() - bridge = CoreCapabilityBridge( - star_context=cast( - Any, - SimpleNamespace( - provider_manager=provider_manager, - platform_manager=SimpleNamespace(get_insts=lambda: [platform]), - get_provider_by_id=lambda provider_id: provider_manager.inst_map.get( - provider_id - ), - get_all_stars=lambda: [ - SimpleNamespace(name="reserved-plugin", reserved=True), - SimpleNamespace(name="plain-plugin", reserved=False), - ], - ), - ), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="reserved/system"): - await bridge._provider_manager_get_insts("plain-request", {}, None) - - output = await bridge._provider_manager_get_insts("reserved-request", {}, None) - assert [item["id"] for item in output["providers"]] == ["chat-main"] - - disabled = await bridge._provider_manager_get_by_id( - "reserved-request", - {"provider_id": "chat-disabled"}, - None, - ) - assert disabled["provider"]["loaded"] is False - assert disabled["provider"]["enabled"] is False - - stream_exec = await bridge._provider_manager_watch_changes( - "reserved-request", - {}, - SimpleNamespace(raise_if_cancelled=lambda: None), - ) - - async def _next_bridge_provider_change() -> Any: - return await anext(stream_exec.iterator) - - waiter = asyncio.create_task(_next_bridge_provider_change()) - await asyncio.sleep(0) - provider_manager.fire_change("chat-main", "chat_completion", "umo-1") - event = await asyncio.wait_for(waiter, timeout=1) - assert event == { - "provider_id": "chat-main", - "provider_type": "chat_completion", - "umo": "umo-1", - } - await cast(Any, stream_exec.iterator).aclose() - assert provider_manager._hooks == [] - - platform_snapshot = await bridge._platform_manager_get_by_id( - "reserved-request", - {"platform_id": "demo-platform"}, - None, - ) - assert platform_snapshot["platform"]["status"] == "error" - assert platform_snapshot["platform"]["unified_webhook"] is True - assert platform_snapshot["platform"]["last_error"]["message"] == "broken" - - await bridge._platform_manager_clear_errors( - "reserved-request", - {"platform_id": "demo-platform"}, - None, - ) - stats = await bridge._platform_manager_get_stats( - "reserved-request", - {"platform_id": "demo-platform"}, - None, - ) - assert stats["stats"]["status"] == "running" - assert stats["stats"]["error_count"] == 0 - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_merged_provider_config_reserved_gate() -> None: - provider_manager = _FakeProviderManager() - bridge = CoreCapabilityBridge( - star_context=cast( - Any, - SimpleNamespace( - provider_manager=provider_manager, - platform_manager=SimpleNamespace(get_insts=lambda: []), - get_provider_by_id=lambda provider_id: provider_manager.inst_map.get( - provider_id - ), - get_all_stars=lambda: [ - SimpleNamespace(name="reserved-plugin", reserved=True), - SimpleNamespace(name="plain-plugin", reserved=False), - ], - ), - ), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="reserved/system"): - await bridge._provider_manager_get_merged_provider_config( - "plain-request", - {"provider_id": "chat-main"}, - None, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_merged_provider_config_returns_merged_shape() -> None: - provider_manager = _FakeProviderManager() - bridge = CoreCapabilityBridge( - star_context=cast( - Any, - SimpleNamespace( - provider_manager=provider_manager, - platform_manager=SimpleNamespace(get_insts=lambda: []), - get_provider_by_id=lambda provider_id: provider_manager.inst_map.get( - provider_id - ), - get_all_stars=lambda: [ - SimpleNamespace(name="reserved-plugin", reserved=True), - SimpleNamespace(name="plain-plugin", reserved=False), - ], - ), - ), - plugin_bridge=_FakePluginBridge(), - ) - - output = await bridge._provider_manager_get_merged_provider_config( - "reserved-request", - {"provider_id": "chat-main"}, - None, - ) - - expected_config = provider_manager.get_merged_provider_config( - provider_manager.providers_config[0] - ) - - assert output["config"]["id"] == "chat-main" - assert output["config"]["endpoint"] == "https://example.invalid/v1" - assert output["config"]["headers"] == {"x-source": "provider-source"} - assert output["config"]["model_config"] == {"temperature": 0.6} - assert output["config"]["provider_settings"] == { - "headers": {"x-provider": "chat-main"}, - "options": { - "temperature": 0.6, - "penalties": {"frequency": 0.1}, - }, - } - assert output["config"]["source_metadata"] == { - "routing": { - "primary": "edge-a", - "fallbacks": ["edge-b", "edge-c"], - } - } - assert set(output["config"].keys()) == set(expected_config.keys()) - assert output["config"] == expected_config - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_core_bridge_merged_provider_config_unknown_provider_fails() -> None: - provider_manager = _FakeProviderManager() - bridge = CoreCapabilityBridge( - star_context=cast( - Any, - SimpleNamespace( - provider_manager=provider_manager, - platform_manager=SimpleNamespace(get_insts=lambda: []), - get_provider_by_id=lambda provider_id: provider_manager.inst_map.get( - provider_id - ), - get_all_stars=lambda: [ - SimpleNamespace(name="reserved-plugin", reserved=True), - SimpleNamespace(name="plain-plugin", reserved=False), - ], - ), - ), - plugin_bridge=_FakePluginBridge(), - ) - - with pytest.raises(AstrBotError, match="unknown provider_id"): - await bridge._provider_manager_get_merged_provider_config( - "reserved-request", - {"provider_id": "missing-provider"}, - None, - ) diff --git a/tests/test_sdk/unit/test_sdk_provider_tool_platform_capabilities.py b/tests/test_sdk/unit/test_sdk_provider_tool_platform_capabilities.py deleted file mode 100644 index 4d5888412..000000000 --- a/tests/test_sdk/unit/test_sdk_provider_tool_platform_capabilities.py +++ /dev/null @@ -1,775 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import asyncio -import json -from types import SimpleNamespace - -import pytest -from astrbot_sdk.context import CancelToken -from astrbot_sdk.context import Context as RuntimeContext -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.llm.agents import AgentSpec -from astrbot_sdk.llm.entities import LLMToolSpec, ProviderMeta, ProviderRequest -from astrbot_sdk.llm.providers import ( - EmbeddingProvider, - RerankProvider, - STTProvider, - TTSProvider, -) -from astrbot_sdk.runtime.capability_dispatcher import CapabilityDispatcher -from astrbot_sdk.runtime.loader import LoadedLLMTool -from astrbot_sdk.testing import MockContext - -from astrbot.core.sdk_bridge import capability_bridge as capability_bridge_module - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_provider_queries_and_tool_manager() -> None: - ctx = MockContext(plugin_id="sdk_demo_agent_tools") - ctx.router.set_provider_catalog( - "chat", - [ - ProviderMeta( - id="chat-provider-1", - model="gpt-test", - type="mock", - provider_type="chat_completion", - ).to_payload() - ], - active_id="chat-provider-1", - ) - ctx.router.set_plugin_llm_tools( - "sdk_demo_agent_tools", - [ - LLMToolSpec( - name="sdk_static_note", - description="static tool", - parameters_schema={"type": "object", "properties": {}}, - active=True, - ).to_payload() - ], - ) - ctx.router.set_plugin_agents( - "sdk_demo_agent_tools", - [ - AgentSpec( - name="sdk_demo_note_agent", - description="demo agent", - tool_names=["sdk_static_note"], - runner_class="demo.Agent", - ).to_payload() - ], - ) - - current = await ctx.get_using_provider() - assert current is not None - assert current.id == "chat-provider-1" - assert await ctx.get_current_chat_provider_id() == "chat-provider-1" - assert [item.id for item in await ctx.get_all_providers()] == ["chat-provider-1"] - - manager = ctx.get_llm_tool_manager() - assert [item.name for item in await manager.list_registered()] == [ - "sdk_static_note" - ] - assert [item.name for item in await manager.list_active()] == ["sdk_static_note"] - assert await ctx.deactivate_llm_tool("sdk_static_note") is True - assert await manager.list_active() == [] - assert await ctx.activate_llm_tool("sdk_static_note") is True - - added = await ctx.add_llm_tools( - LLMToolSpec( - name="sdk_dynamic_note", - description="dynamic tool", - parameters_schema={"type": "object", "properties": {}}, - active=True, - ) - ) - assert added == ["sdk_dynamic_note"] - assert sorted(item.name for item in await manager.list_registered()) == [ - "sdk_dynamic_note", - "sdk_static_note", - ] - - response = await ctx.tool_loop_agent( - ProviderRequest(prompt="hello", tool_names=["sdk_static_note"]) - ) - assert response.text == "Mock tool loop: hello tools=sdk_static_note" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_mock_context_provider_client_and_specialized_proxies() -> None: - ctx = MockContext(plugin_id="sdk_demo_agent_tools") - ctx.router.set_provider_catalog( - "tts", - [ - { - "id": "tts-provider-1", - "model": "tts-model", - "type": "mock", - "provider_type": "text_to_speech", - } - ], - active_id="tts-provider-1", - ) - ctx.router.set_provider_catalog( - "stt", - [ - { - "id": "stt-provider-1", - "model": "stt-model", - "type": "mock", - "provider_type": "speech_to_text", - } - ], - active_id="stt-provider-1", - ) - ctx.router.set_provider_catalog( - "embedding", - [ - { - "id": "embedding-provider-1", - "model": "embedding-model", - "type": "mock", - "provider_type": "embedding", - } - ], - ) - ctx.router.set_provider_catalog( - "rerank", - [ - { - "id": "rerank-provider-1", - "model": "rerank-model", - "type": "mock", - "provider_type": "rerank", - } - ], - ) - - assert [item.id for item in await ctx.providers.list_tts()] == ["tts-provider-1"] - assert [item.id for item in await ctx.providers.list_stt()] == ["stt-provider-1"] - assert [item.id for item in await ctx.providers.list_embedding()] == [ - "embedding-provider-1" - ] - assert [item.id for item in await ctx.providers.list_rerank()] == [ - "rerank-provider-1" - ] - assert [item.id for item in await ctx.get_all_rerank_providers()] == [ - "rerank-provider-1" - ] - - tts = await ctx.providers.get("tts-provider-1") - stt = await ctx.providers.get("stt-provider-1") - embedding = await ctx.providers.get("embedding-provider-1") - rerank = await ctx.providers.get("rerank-provider-1") - - assert isinstance(tts, TTSProvider) - assert isinstance(stt, STTProvider) - assert isinstance(embedding, EmbeddingProvider) - assert isinstance(rerank, RerankProvider) - assert await ctx.providers.get("missing-provider") is None - assert await ctx.providers.get("mock-chat-provider") is None - - assert await stt.get_text("https://example.com/audio.wav") == ( - "Mock transcript: https://example.com/audio.wav" - ) - assert await tts.get_audio("hello sdk") == "mock://tts/tts-provider-1/hello sdk" - assert tts.support_stream() is True - - single_chunks = [chunk async for chunk in tts.get_audio_stream("hello stream")] - assert len(single_chunks) == 1 - assert single_chunks[0].text == "hello stream" - assert single_chunks[0].audio == b"mock-audio:hello stream" - - async def text_source(): - yield "hello" - yield "sdk" - - streamed_chunks = [chunk async for chunk in tts.get_audio_stream(text_source())] - assert [chunk.text for chunk in streamed_chunks] == ["hello", "sdk"] - assert [chunk.audio for chunk in streamed_chunks] == [ - b"mock-audio:hello", - b"mock-audio:sdk", - ] - - single_embedding = await embedding.get_embedding("AstrBot") - batch_embeddings = await embedding.get_embeddings(["AstrBot", "SDK"]) - embedding_dim = await embedding.get_dim() - - assert len(single_embedding) == embedding_dim - assert len(batch_embeddings) == 2 - assert batch_embeddings[0] == single_embedding - assert all(len(item) == embedding_dim for item in batch_embeddings) - assert embedding_dim > 0 - - reranked = await rerank.rerank( - "hello sdk", - ["hello world", "sdk helper", "other"], - top_n=2, - ) - assert [(item.document, item.score) for item in reranked] == [ - ("hello world", 1.0), - ("sdk helper", 1.0), - ] - - using_tts = await ctx.providers.get_using_tts() - using_stt = await ctx.providers.get_using_stt() - assert isinstance(using_tts, TTSProvider) - assert isinstance(using_stt, STTProvider) - assert (await ctx.get_using_tts_provider()) is not None - assert (await ctx.get_using_stt_provider()) is not None - - -# Note: legacy loader discovery test removed. -# as it depends on missing demo plugin directory - - -class _SlowSession: - async def invoke_capability( - self, _capability: str, _payload: dict, *, request_id: str - ): - await asyncio.sleep(0.05) - return {"request_id": request_id} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_sdk_tool_bridge_wraps_timeout_as_failed_tool_result( - monkeypatch: pytest.MonkeyPatch, -) -> None: - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._plugin_bridge = SimpleNamespace( - _records={"sdk_demo_agent_tools": SimpleNamespace(session=_SlowSession())}, - _get_dispatch_token=lambda _event: "dispatch-token", - ) - monkeypatch.setattr( - capability_bridge_module.EventConverter, - "core_to_sdk", - lambda *_args, **_kwargs: {"session_id": "local-session", "text": "hello"}, - ) - - handler = bridge._make_sdk_tool_handler( - plugin_id="sdk_demo_agent_tools", - tool_spec=LLMToolSpec( - name="sdk_static_note", - description="static tool", - parameters_schema={"type": "object", "properties": {}}, - handler_ref="sdk_static_note", - active=True, - ), - tool_call_timeout=0.01, - ) - - output = await handler(object(), query="slow") - assert isinstance(output, str) - payload = json.loads(output) - assert payload["tool_name"] == "sdk_static_note" - assert payload["success"] is False - assert "timeout" in payload["content"].lower() - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_registered_llm_tool_rejects_non_mapping_tool_args() -> None: - async def required_tool(required_text: str) -> str: - return required_text - - dispatcher = CapabilityDispatcher( - plugin_id="sdk_demo_agent_tools", - peer=object(), - capabilities=[], - llm_tools=[ - LoadedLLMTool( - spec=LLMToolSpec( - name="required_tool", - description="requires a string argument", - parameters_schema={"type": "object", "properties": {}}, - active=True, - ), - callable=required_tool, - owner=object(), - plugin_id="sdk_demo_agent_tools", - ) - ], - ) - - message = SimpleNamespace( - id="tool-call-1", - capability="internal.llm_tool.execute", - input={ - "plugin_id": "sdk_demo_agent_tools", - "tool_name": "required_tool", - "tool_args": "not-a-dict", - "event": "invalid-event-payload", - }, - ) - - with pytest.raises(TypeError, match="missing required argument 'required_text'"): - await dispatcher.invoke(message, CancelToken()) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_registered_llm_tool_injects_pep604_optional_event_and_context() -> None: - async def optional_tool( - event: MessageEvent | None = None, - ctx: RuntimeContext | None = None, - ) -> str: - assert event is not None - assert ctx is not None - return f"{ctx.plugin_id}:{event.session_id}" - - dispatcher = CapabilityDispatcher( - plugin_id="sdk_demo_agent_tools", - peer=object(), - capabilities=[], - llm_tools=[ - LoadedLLMTool( - spec=LLMToolSpec( - name="optional_tool", - description="uses optional event/context injections", - parameters_schema={"type": "object", "properties": {}}, - active=True, - ), - callable=optional_tool, - owner=object(), - plugin_id="sdk_demo_agent_tools", - ) - ], - ) - - message = SimpleNamespace( - id="tool-call-2", - capability="internal.llm_tool.execute", - input={ - "plugin_id": "sdk_demo_agent_tools", - "tool_name": "optional_tool", - "tool_args": {}, - "event": {"session_id": "session-42", "text": "hello"}, - }, - ) - - output = await dispatcher.invoke(message, CancelToken()) - assert output == { - "content": "sdk_demo_agent_tools:session-42", - "success": True, - } - - -@pytest.mark.unit -def test_provider_to_payload_normalizes_core_provider_type_enum() -> None: - from astrbot.core.provider.entities import ProviderMeta as CoreProviderMeta - from astrbot.core.provider.entities import ProviderType as CoreProviderType - - provider = SimpleNamespace( - meta=lambda: CoreProviderMeta( - id="provider-1", - model="gpt-test", - type="openai", - provider_type=CoreProviderType.CHAT_COMPLETION, - ) - ) - - payload = capability_bridge_module.CoreCapabilityBridge._provider_to_payload( - provider - ) - assert payload is not None - assert payload["provider_type"] == "chat_completion" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_agent_tool_loop_run_accepts_dict_contexts_from_sdk_payload() -> None: - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - captured: dict[str, object] = {} - - class _FakeStarContext: - async def tool_loop_agent(self, **kwargs): - captured.update(kwargs) - return SimpleNamespace( - completion_text="done", - usage=None, - tools_call_ids=[], - role="assistant", - reasoning_content="", - reasoning_signature=None, - to_openai_tool_calls=lambda: [], - ) - - bridge._star_context = _FakeStarContext() - bridge._resolve_plugin_id = lambda _request_id: "sdk_demo_agent_tools" - bridge._resolve_event_request_context = lambda _request_id, _payload: ( - SimpleNamespace(event="fake-event") - ) - bridge._resolve_current_chat_provider_id = lambda _request_context: "provider-1" - bridge._build_sdk_toolset = lambda **_kwargs: None - - payload = { - "prompt": "hello", - "contexts": [{"role": "user", "content": "from-sdk"}], - } - output = await bridge._agent_tool_loop_run("request-1", payload, None) - - assert output["text"] == "done" - assert captured["contexts"] == payload["contexts"] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_platform_send_by_session_supports_proactive_send_without_dispatch_token() -> ( - None -): - sent: dict[str, object] = {} - - async def fake_send_message(session: str, chain) -> None: - sent["session"] = session - sent["chain"] = chain - - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._star_context = SimpleNamespace(send_message=fake_send_message) - bridge._plugin_bridge = SimpleNamespace( - resolve_request_session=lambda _request_id: None, - before_platform_send=lambda _dispatch_token: None, - mark_platform_send=lambda _dispatch_token: "should-not-be-used", - get_request_context_by_token=lambda _dispatch_token: None, - ) - - output = await bridge._platform_send_by_session( - "request-1", - { - "session": "demo:private:user-1", - "chain": [{"type": "text", "data": {"text": "hello proactive"}}], - }, - None, - ) - - assert sent["session"] == "demo:private:user-1" - assert sent["chain"].get_plain_text() == "hello proactive" - assert output["message_id"].startswith("sdk_proactive_") - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_platform_get_group_and_members_are_current_event_only() -> None: - class _FakeEvent: - unified_msg_origin = "demo:group:room-7" - - async def get_group(self): - member = SimpleNamespace(user_id="user-1", nickname="Alice", role="admin") - return SimpleNamespace( - group_id="room-7", - group_name="Room 7", - group_avatar="", - group_owner="owner-1", - group_admins=["owner-1", "user-1"], - members=[member], - ) - - request_context = SimpleNamespace( - event=_FakeEvent(), - cancelled=False, - dispatch_token="dispatch-1", - ) - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._plugin_bridge = SimpleNamespace( - resolve_request_session=lambda _request_id: request_context, - get_request_context_by_token=lambda _dispatch_token: request_context, - ) - - group = await bridge._platform_get_group( - "request-1", - {"session": "demo:group:room-7"}, - None, - ) - members = await bridge._platform_get_members( - "request-1", - {"session": "demo:group:room-7"}, - None, - ) - - assert group["group"]["group_id"] == "room-7" - assert members["members"] == [ - {"user_id": "user-1", "nickname": "Alice", "role": "admin"} - ] - - with pytest.raises(AstrBotError, match="current event session"): - await bridge._platform_get_members( - "request-1", - {"session": "demo:group:another-room"}, - None, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_platform_list_instances_uses_platform_manager_metadata() -> None: - class _FakeMeta: - def __init__(self, platform_id: str, name: str, display_name: str) -> None: - self.id = platform_id - self.name = name - self.adapter_display_name = display_name - - class _FakePlatform: - def __init__(self, platform_id: str, name: str, display_name: str) -> None: - self._meta = _FakeMeta(platform_id, name, display_name) - self.status = SimpleNamespace(value="running") - - def meta(self): - return self._meta - - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._star_context = SimpleNamespace( - platform_manager=SimpleNamespace( - get_insts=lambda: [ - _FakePlatform("qq-main", "qq_official", "QQ"), - _FakePlatform("webchat", "webchat", "WebChat"), - ] - ) - ) - - output = await bridge._platform_list_instances("request-1", {}, None) - assert output == { - "platforms": [ - { - "id": "qq-main", - "name": "QQ", - "type": "qq_official", - "status": "running", - }, - { - "id": "webchat", - "name": "WebChat", - "type": "webchat", - "status": "running", - }, - ] - } - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_registry_command_register_validates_and_forwards_to_bridge() -> None: - captured: dict[str, object] = {} - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._resolve_plugin_id = lambda _request_id: "sdk-demo" - bridge._plugin_bridge = SimpleNamespace( - register_dynamic_command_route=lambda **kwargs: captured.update(kwargs) - ) - - await bridge._registry_command_register( - "request-1", - { - "source_event_type": "astrbot_loaded", - "command_name": "hello", - "handler_full_name": "sdk-demo:demo.handler", - "desc": "demo", - "priority": 3, - "use_regex": True, - }, - None, - ) - assert captured == { - "plugin_id": "sdk-demo", - "command_name": "hello", - "handler_full_name": "sdk-demo:demo.handler", - "desc": "demo", - "priority": 3, - "use_regex": True, - } - - with pytest.raises(AstrBotError, match="astrbot_loaded/platform_loaded"): - await bridge._registry_command_register( - "request-2", - { - "source_event_type": "message", - "command_name": "hello", - "handler_full_name": "sdk-demo:demo.handler", - }, - None, - ) - - with pytest.raises(AstrBotError, match="ignore_prefix=True"): - await bridge._registry_command_register( - "request-3", - { - "source_event_type": "platform_loaded", - "command_name": "hello", - "handler_full_name": "sdk-demo:demo.handler", - "ignore_prefix": True, - }, - None, - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_skill_capabilities_forward_to_bridge_and_sync( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sync_calls: list[str] = [] - - async def _fake_sync() -> None: - sync_calls.append("synced") - - monkeypatch.setattr( - "astrbot.core.computer.computer_client.sync_skills_to_active_sandboxes", - _fake_sync, - ) - - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._resolve_plugin_id = lambda _request_id: "sdk-demo" - bridge._plugin_bridge = SimpleNamespace( - register_skill=lambda **kwargs: { - "name": kwargs["name"], - "description": kwargs["description"], - "path": kwargs["path"], - "skill_dir": "/tmp/skill", - }, - unregister_skill=lambda **kwargs: True, - list_registered_skills=lambda plugin_id: [ - { - "name": "sdk-demo.browser-helper", - "description": "demo skill", - "path": "/tmp/skill/SKILL.md", - "skill_dir": "/tmp/skill", - } - ] - if plugin_id == "sdk-demo" - else [], - ) - - registered = await bridge._skill_register( - "request-1", - { - "name": "sdk-demo.browser-helper", - "description": "demo skill", - "path": "skills/browser-helper", - }, - None, - ) - assert registered == { - "name": "sdk-demo.browser-helper", - "description": "demo skill", - "path": "skills/browser-helper", - "skill_dir": "/tmp/skill", - } - - listed = await bridge._skill_list("request-2", {}, None) - assert listed == { - "skills": [ - { - "name": "sdk-demo.browser-helper", - "description": "demo skill", - "path": "/tmp/skill/SKILL.md", - "skill_dir": "/tmp/skill", - } - ] - } - - removed = await bridge._skill_unregister( - "request-3", - {"name": "sdk-demo.browser-helper"}, - None, - ) - assert removed == {"removed": True} - assert sync_calls == ["synced", "synced"] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_session_plugin_and_service_capabilities_reuse_existing_sp_keys( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class _FakeSp: - def __init__(self) -> None: - self.store = { - ("umo", "demo:group:room-7", "session_plugin_config"): { - "demo:group:room-7": {"disabled_plugins": ["sdk-disabled"]} - }, - ("umo", "demo:group:room-7", "session_service_config"): { - "llm_enabled": False, - "tts_enabled": True, - }, - } - - async def get_async(self, scope, scope_id, key, default=None): - return self.store.get((scope, scope_id, key), default) - - async def put_async(self, scope, scope_id, key, value): - self.store[(scope, scope_id, key)] = value - - fake_sp = _FakeSp() - monkeypatch.setattr( - "astrbot.core.sdk_bridge.capabilities.session._get_runtime_sp", - lambda: fake_sp, - ) - - bridge = object.__new__(capability_bridge_module.CoreCapabilityBridge) - bridge._star_context = SimpleNamespace( - get_all_stars=lambda: [SimpleNamespace(name="sdk-reserved", reserved=True)] - ) - - enabled = await bridge._session_plugin_is_enabled( - "request-1", - {"session": "demo:group:room-7", "plugin_name": "sdk-disabled"}, - None, - ) - filtered = await bridge._session_plugin_filter_handlers( - "request-1", - { - "session": "demo:group:room-7", - "handlers": [ - { - "plugin_name": "sdk-disabled", - "handler_full_name": "sdk-disabled:main.on_message", - "trigger_type": "message", - "event_types": [], - "enabled": True, - "group_path": [], - }, - { - "plugin_name": "sdk-reserved", - "handler_full_name": "sdk-reserved:main.on_message", - "trigger_type": "message", - "event_types": [], - "enabled": True, - "group_path": [], - }, - ], - }, - None, - ) - llm_enabled = await bridge._session_service_is_llm_enabled( - "request-1", - {"session": "demo:group:room-7"}, - None, - ) - tts_enabled = await bridge._session_service_is_tts_enabled( - "request-1", - {"session": "demo:group:room-7"}, - None, - ) - - await bridge._session_service_set_llm_status( - "request-1", - {"session": "demo:group:room-7", "enabled": True}, - None, - ) - await bridge._session_service_set_tts_status( - "request-1", - {"session": "demo:group:room-7", "enabled": False}, - None, - ) - - assert enabled == {"enabled": False} - assert [item["plugin_name"] for item in filtered["handlers"]] == ["sdk-reserved"] - assert llm_enabled == {"enabled": False} - assert tts_enabled == {"enabled": True} - assert fake_sp.store[("umo", "demo:group:room-7", "session_service_config")] == { - "llm_enabled": True, - "tts_enabled": False, - } diff --git a/tests/test_sdk/unit/test_sdk_vnext_author_experience.py b/tests/test_sdk/unit/test_sdk_vnext_author_experience.py deleted file mode 100644 index 34af7c4f1..000000000 --- a/tests/test_sdk/unit/test_sdk_vnext_author_experience.py +++ /dev/null @@ -1,1435 +0,0 @@ -# ruff: noqa: E402 -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -import astrbot_sdk.runtime.supervisor as supervisor_module -import pytest -from astrbot_sdk._command_model import ( - parse_command_model_remainder, - resolve_command_model_param, -) -from astrbot_sdk.cli import EXIT_RUNTIME, _run_sync_entrypoint -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.conversation import ( - ConversationClosed, - ConversationReplaced, - ConversationSession, - ConversationState, -) -from astrbot_sdk.decorators import ( - ConversationMeta, - LimiterMeta, - admin_only, - conversation_command, - cooldown, - get_handler_meta, - group_only, - message_types, - on_command, - on_event, - on_message, - on_schedule, - platforms, - priority, - private_only, - rate_limit, -) -from astrbot_sdk.errors import AstrBotError, ErrorCodes -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.message_components import File, Image, MediaHelper, Record -from astrbot_sdk.message_result import MessageBuilder, MessageChain -from astrbot_sdk.protocol.descriptors import ( - CapabilityDescriptor, - CommandTrigger, - HandlerDescriptor, - MessageTypeFilterSpec, - Permissions, - PlatformFilterSpec, - SessionRef, -) -from astrbot_sdk.runtime.capability_dispatcher import CapabilityDispatcher -from astrbot_sdk.runtime.environment_groups import EnvironmentPlanResult -from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher -from astrbot_sdk.runtime.limiter import LimiterEngine -from astrbot_sdk.runtime.loader import ( - LoadedCapability, - LoadedHandler, - PluginDiscoveryIssue, - PluginDiscoveryResult, - discover_plugins, - load_plugin, - load_plugin_spec, - validate_plugin_spec, -) -from astrbot_sdk.runtime.supervisor import SupervisorRuntime -from astrbot_sdk.runtime.worker import GroupWorkerRuntime -from astrbot_sdk.star import Star -from astrbot_sdk.testing import MockClock, SDKTestEnvironment -from click.testing import CliRunner -from pydantic import BaseModel, Field - -from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge - - -class _Peer: - def __init__(self) -> None: - descriptor = SimpleNamespace(supports_stream=False) - self.remote_peer = {"name": "dummy-core"} - self.remote_capability_map = { - "platform.send": descriptor, - "platform.send_chain": descriptor, - "platform.send_by_session": descriptor, - "system.session_waiter.register": descriptor, - "system.session_waiter.unregister": descriptor, - } - self.sent_messages: list[dict[str, object]] = [] - self.waiter_ops: list[dict[str, object]] = [] - - async def invoke( - self, - capability: str, - payload: dict[str, object], - *, - stream: bool = False, - request_id: str | None = None, - ) -> dict[str, object]: - if stream: - raise AssertionError("unexpected stream invoke") - if capability == "platform.send": - self.sent_messages.append( - { - "kind": "text", - "session": payload.get("session"), - "text": payload.get("text"), - } - ) - return {"message_id": f"text-{len(self.sent_messages)}"} - if capability in {"platform.send_chain", "platform.send_by_session"}: - self.sent_messages.append( - { - "kind": "chain", - "session": payload.get("session"), - "chain": payload.get("chain"), - } - ) - return {"message_id": f"chain-{len(self.sent_messages)}"} - if capability in { - "system.session_waiter.register", - "system.session_waiter.unregister", - }: - self.waiter_ops.append({"capability": capability, **payload}) - return {} - raise AssertionError(f"unexpected capability: {capability}") - - -def _event_payload( - text: str, *, session_id: str = "demo:private:user-1" -) -> dict[str, object]: - return { - "text": text, - "session_id": session_id, - "user_id": "user-1", - "group_id": None, - "platform": "demo", - "platform_id": "demo", - "message_type": "private", - "target": SessionRef(conversation_id=session_id, platform="demo").to_payload(), - } - - -class _BridgeStarContext: - def __init__(self) -> None: - self.registered_web_apis = [] - self.cron_manager = None - - def get_all_stars(self) -> list[object]: - return [] - - -class _ReplyCollector: - def __init__(self) -> None: - self.replies: list[str] = [] - - async def reply(self, text: str) -> None: - self.replies.append(text) - - -def _write_sdk_plugin( - plugin_dir: Path, - *, - name: str, - main_source: str, -) -> Path: - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "plugin.yaml").write_text( - "\n".join( - [ - f"name: {name}", - "runtime:", - ' python: "3.11"', - "components:", - " - class: main:DemoPlugin", - ] - ), - encoding="utf-8", - ) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "main.py").write_text(main_source, encoding="utf-8") - return plugin_dir - - -@pytest.mark.unit -def test_decorator_alias_and_conflict_rules() -> None: - @on_command(["echo", "repeat", "say"]) - async def echo(event: MessageEvent, ctx: Context) -> None: ... - - meta = get_handler_meta(echo) - assert meta is not None - assert isinstance(meta.trigger, CommandTrigger) - assert meta.trigger.command == "echo" - assert meta.trigger.aliases == ["repeat", "say"] - - with pytest.raises(ValueError, match="platforms"): - - @platforms("qq") - @on_message(platforms=["wechat"]) - async def _platform_conflict(event: MessageEvent, ctx: Context) -> None: ... - - with pytest.raises(ValueError, match="消息类型约束"): - - @group_only() - @private_only() - async def _scope_conflict(event: MessageEvent, ctx: Context) -> None: ... - - with pytest.raises(ValueError, match="不能叠加"): - - @rate_limit(1, 60) - @cooldown(10) - async def _limiter_conflict(event: MessageEvent, ctx: Context) -> None: ... - - with pytest.raises(ValueError, match="只适用于 on_command/on_message"): - - @on_event("ready") - @rate_limit(1, 60) - async def _event_limiter_conflict(ctx: Context) -> None: ... - - @conversation_command("quiz", timeout=12, mode="reject", busy_message="busy") - async def quiz( - event: MessageEvent, - conversation: ConversationSession, - ctx: Context, - ) -> None: ... - - conversation_meta = get_handler_meta(quiz) - assert conversation_meta is not None - assert conversation_meta.conversation == ConversationMeta( - timeout=12, - mode="reject", - busy_message="busy", - grace_period=1.0, - ) - - -@pytest.mark.unit -def test_handler_descriptions_flow_from_decorators_to_loaded_descriptors( - tmp_path: Path, -) -> None: - @on_message(keywords=["hello"], description="Handle hello messages") - async def hello(event: MessageEvent, ctx: Context) -> None: ... - - @on_event("ready", description="React when the runtime is ready") - async def ready(event: MessageEvent, ctx: Context) -> None: ... - - @on_schedule(interval_seconds=60, description="Run periodic maintenance") - async def tick(ctx: Context) -> None: ... - - hello_meta = get_handler_meta(hello) - ready_meta = get_handler_meta(ready) - tick_meta = get_handler_meta(tick) - assert hello_meta is not None - assert ready_meta is not None - assert tick_meta is not None - assert hello_meta.description == "Handle hello messages" - assert ready_meta.description == "React when the runtime is ready" - assert tick_meta.description == "Run periodic maintenance" - - env = SDKTestEnvironment(tmp_path) - plugin_dir = _write_sdk_plugin( - env.plugin_dir("handler_descriptions"), - name="handler_descriptions", - main_source="\n".join( - [ - ( - "from astrbot_sdk import Context, MessageEvent, Star, " - "on_command, on_event, on_message, on_schedule" - ), - "", - "class DemoPlugin(Star):", - ' @on_command("hello", description="Say hello politely")', - " async def hello(self, event: MessageEvent, ctx: Context) -> None:", - ' await event.reply("hi")', - "", - ' @on_message(keywords=["ping"], description="React to ping messages")', - " async def ping(self, event: MessageEvent, ctx: Context) -> None:", - ' await event.reply("pong")', - "", - ' @on_event("ready", description="Observe ready events")', - " async def ready(self, event: MessageEvent, ctx: Context) -> None:", - " return None", - "", - ' @on_schedule(interval_seconds=60, description="Run periodic maintenance")', - " async def tick(self, ctx: Context) -> None:", - " return None", - ] - ), - ) - - plugin = load_plugin_spec(plugin_dir) - validate_plugin_spec(plugin) - loaded = load_plugin(plugin) - descriptors = { - handler.descriptor.id.rsplit(".", 1)[-1]: handler.descriptor - for handler in loaded.handlers - } - - assert descriptors["hello"].description == "Say hello politely" - assert isinstance(descriptors["hello"].trigger, CommandTrigger) - assert descriptors["hello"].trigger.description == "Say hello politely" - assert descriptors["ping"].description == "React to ping messages" - assert descriptors["ready"].description == "Observe ready events" - assert descriptors["tick"].description == "Run periodic maintenance" - - @admin_only - @priority(7) - @message_types("group") - @platforms("qq", "wechat") - @on_message(keywords=["hello"]) - async def filtered(event: MessageEvent, ctx: Context) -> None: ... - - filtered_meta = get_handler_meta(filtered) - assert filtered_meta is not None - assert filtered_meta.priority == 7 - assert filtered_meta.permissions == Permissions(require_admin=True) - assert filtered_meta.filters == [ - PlatformFilterSpec(platforms=["qq", "wechat"]), - MessageTypeFilterSpec(message_types=["group"]), - ] - - -class _EchoInput(BaseModel): - text: str = Field(description="echo text") - times: int = Field(default=1, ge=1, le=5) - loud: bool | None = None - - -async def _echo_handler( - event: MessageEvent, - params: _EchoInput, - ctx: Context, -) -> None: - for _ in range(params.times): - await event.reply(params.text.upper() if params.loud else params.text) - - -@pytest.mark.unit -def test_command_model_parser_help_and_duplicates() -> None: - model_param = resolve_command_model_param(_echo_handler) - assert model_param is not None - - parsed = parse_command_model_remainder( - remainder="hello --times 2 --loud", - model_param=model_param, - command_name="echo", - ) - assert parsed.help_text is None - assert parsed.model is not None - assert parsed.model.model_dump() == {"text": "hello", "times": 2, "loud": True} - - equals_and_override = parse_command_model_remainder( - remainder="hello 3 --text=override --no-loud", - model_param=model_param, - command_name="echo", - ) - assert equals_and_override.model is not None - assert equals_and_override.model.model_dump() == { - "text": "override", - "times": 3, - "loud": False, - } - - help_result = parse_command_model_remainder( - remainder="--help --unknown nope", - model_param=model_param, - command_name="echo", - ) - assert help_result.model is None - assert help_result.help_text is not None - assert "用法: /echo" in help_result.help_text - - with pytest.raises(AstrBotError, match="Duplicate field"): - parse_command_model_remainder( - remainder="--text a --text b", - model_param=model_param, - command_name="echo", - ) - - with pytest.raises(AstrBotError, match="Unknown field"): - parse_command_model_remainder( - remainder="--unknown nope", - model_param=model_param, - command_name="echo", - ) - - with pytest.raises(AstrBotError, match="Too many positional arguments"): - parse_command_model_remainder( - remainder="hello 2 extra", - model_param=model_param, - command_name="echo", - ) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_plugin_logger_watch_and_default_on_error_render_details() -> None: - ctx = Context(peer=_Peer(), plugin_id="sdk-demo") - watcher = ctx.logger.watch() - bound_logger = ctx.logger.bind( - request_id="req-1", - handler_ref="sdk-demo:test.handle", - session_id="demo:private:user-1", - event_type="message", - ) - - async def _next_entry(): - return await watcher.__anext__() - - pending = asyncio.create_task(_next_entry()) - await asyncio.sleep(0) - bound_logger.info("hello {}", "sdk") - entry = await pending - - assert entry.plugin_id == "sdk-demo" - assert entry.message == "hello sdk" - assert entry.context == { - "request_id": "req-1", - "handler_ref": "sdk-demo:test.handle", - "session_id": "demo:private:user-1", - "event_type": "message", - } - - await watcher.aclose() - - error = AstrBotError.invalid_input( - "bad input", - hint="fix it", - docs_url="https://docs.astrbot.org/sdk/errors#invalid-input", - details={"field": "name"}, - ) - event = _ReplyCollector() - - await Star().on_error(error, event, ctx) - - assert event.replies == [ - 'fix it\n文档:https://docs.astrbot.org/sdk/errors#invalid-input\n详情:{"field": "name"}' - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_request_logger_binding_for_handler_and_capability_paths() -> None: - peer = _Peer() - watcher = Context(peer=peer, plugin_id="sdk-demo").logger.watch() - - class _LoggerPlugin(Star): - async def handle(self, event: MessageEvent, ctx: Context) -> None: - ctx.logger.info("handler log") - - async def capability( - self, payload: dict[str, object], ctx: Context - ) -> dict[str, object]: - ctx.logger.info("capability log") - return {"ok": True} - - async def _next_entry(): - return await watcher.__anext__() - - owner = _LoggerPlugin() - handler_dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=peer, - handlers=[ - LoadedHandler( - descriptor=HandlerDescriptor( - id="sdk-demo:test.handle", - trigger=CommandTrigger(command="ping"), - ), - callable=owner.handle, - owner=owner, - plugin_id="sdk-demo", - ) - ], - ) - capability_dispatcher = CapabilityDispatcher( - plugin_id="sdk-demo", - peer=peer, - capabilities=[ - LoadedCapability( - descriptor=CapabilityDescriptor( - name="sdk-demo.echo", - description="echo", - input_schema={"type": "object"}, - output_schema={"type": "object"}, - ), - callable=owner.capability, - owner=owner, - plugin_id="sdk-demo", - ) - ], - ) - - pending_handler = asyncio.create_task(_next_entry()) - await _invoke_handler( - handler_dispatcher, - handler_id="sdk-demo:test.handle", - text="ping", - request_id="h1", - ) - handler_entry = await pending_handler - assert handler_entry.context == { - "plugin_id": "sdk-demo", - "request_id": "h1", - "handler_ref": "sdk-demo:test.handle", - "session_id": "demo:private:user-1", - "event_type": "private", - } - - pending_capability = asyncio.create_task(_next_entry()) - await capability_dispatcher.invoke( - SimpleNamespace( - id="c1", - capability="sdk-demo.echo", - input={"session": "demo:private:user-1"}, - stream=False, - ), - CancelToken(), - ) - capability_entry = await pending_capability - assert capability_entry.context == { - "plugin_id": "sdk-demo", - "request_id": "c1", - "capability": "sdk-demo.echo", - "session_id": "demo:private:user-1", - "event_type": "capability", - } - - await watcher.aclose() - - -@pytest.mark.unit -def test_discovery_issue_surfaces_to_dashboard_failed_item(tmp_path: Path) -> None: - plugins_dir = tmp_path / "plugins" - broken_dir = plugins_dir / "broken" - broken_dir.mkdir(parents=True) - (broken_dir / "plugin.yaml").write_text( - "\n".join( - [ - "name: broken", - "components:", - " - class: main:BrokenPlugin", - ] - ), - encoding="utf-8", - ) - - discovered = discover_plugins(plugins_dir) - - assert discovered.plugins == [] - assert "broken" in discovered.skipped_plugins - assert len(discovered.issues) == 1 - issue = discovered.issues[0] - assert issue.plugin_id == "broken" - assert issue.phase == "discovery" - assert "runtime.python" in issue.details - - bridge = SdkPluginBridge(_BridgeStarContext()) - bridge._set_discovery_issues(discovered.issues) # noqa: SLF001 - - dashboard_items = bridge.list_plugins() - assert dashboard_items == [ - { - "name": "broken", - "repo": "", - "author": "", - "desc": "插件发现失败", - "version": "0.0.0", - "reserved": False, - "activated": False, - "online_vesion": "", - "handlers": [], - "display_name": "broken", - "logo": None, - "support_platforms": [], - "astrbot_version": "", - "installed_at": None, - "runtime_kind": "sdk", - "source_kind": "local_dir", - "managed_by": "sdk_bridge", - "state": "failed", - "trigger_summary": [], - "unsupported_features": [], - "failure_reason": issue.details, - "issues": [issue.to_payload()], - } - ] - - metadata = bridge.get_plugin_metadata("broken") - assert metadata is not None - assert metadata["enabled"] is False - assert metadata["runtime_kind"] == "sdk" - assert metadata["issues"] == [issue.to_payload()] - - -@pytest.mark.unit -def test_loaded_plugin_issue_metadata_is_preserved_in_bridge(tmp_path: Path) -> None: - issue = PluginDiscoveryIssue( - severity="error", - phase="load", - plugin_id="sdk-demo", - message="worker failed", - details="boom", - ) - bridge = SdkPluginBridge(_BridgeStarContext()) - bridge._records = { # noqa: SLF001 - "sdk-demo": SimpleNamespace( - plugin=SimpleNamespace( - name="sdk-demo", - manifest_data={}, - plugin_dir=tmp_path / "sdk-demo", - ), - plugin_id="sdk-demo", - load_order=0, - state="failed", - unsupported_features=[], - config={}, - handlers=[], - llm_tools={}, - active_llm_tools=set(), - agents={}, - dynamic_command_routes=[], - session=None, - restart_attempted=False, - failure_reason="boom", - issues=[issue.to_payload()], - ) - } - - metadata = bridge.get_plugin_metadata("sdk-demo") - assert metadata is not None - assert metadata["issues"] == [issue.to_payload()] - - dashboard_items = bridge.list_plugins() - assert dashboard_items[0]["issues"] == [issue.to_payload()] - - -@pytest.mark.unit -@pytest.mark.parametrize( - ("source_name", "main_source"), - [ - ( - "event_case", - "\n".join( - [ - "from astrbot_sdk import Context, MessageEvent, Star, on_event, rate_limit", - "", - "class DemoPlugin(Star):", - ' @on_event("ready")', - " @rate_limit(1, 60)", - " async def broken(self, event: MessageEvent, ctx: Context) -> None:", - " return None", - ] - ), - ), - ( - "schedule_case", - "\n".join( - [ - "from astrbot_sdk import Context, Star, on_schedule, rate_limit", - "", - "class DemoPlugin(Star):", - " @on_schedule(interval_seconds=60)", - " @rate_limit(1, 60)", - " async def broken(self, ctx: Context) -> None:", - " return None", - ] - ), - ), - ], -) -def test_invalid_limiter_trigger_combinations_fail_during_plugin_load( - tmp_path: Path, - source_name: str, - main_source: str, -) -> None: - env = SDKTestEnvironment(tmp_path) - plugin_dir = _write_sdk_plugin( - env.plugin_dir(source_name), - name=source_name, - main_source=main_source, - ) - - plugin = load_plugin_spec(plugin_dir) - validate_plugin_spec(plugin) - with pytest.raises(ValueError, match="只适用于 on_command/on_message"): - load_plugin(plugin) - - -@pytest.mark.unit -def test_cli_error_render_includes_docs_details_and_context( - capsys: pytest.CaptureFixture[str], -) -> None: - CliRunner() # keep click testing dependency exercised in the SDK test env - - def _boom() -> None: - raise AstrBotError.invalid_input( - "bad input", - hint="fix it", - docs_url="https://docs.astrbot.org/sdk/errors#invalid-input", - details={"field": "name"}, - ) - - with pytest.raises(SystemExit) as exc_info: - _run_sync_entrypoint( - _boom, - log_message="run test entrypoint", - context={"plugin_dir": Path("demo-plugin")}, - ) - - assert exc_info.value.code == EXIT_RUNTIME - captured = capsys.readouterr() - assert "Error[invalid_input]: bad input" in captured.err - assert "Suggestion: fix it" in captured.err - assert "Docs: https://docs.astrbot.org/sdk/errors#invalid-input" in captured.err - assert "Details: {'field': 'name'}" in captured.err - assert "plugin_dir: demo-plugin" in captured.err - - -@pytest.mark.unit -def test_group_worker_metadata_serializes_issues() -> None: - runtime = object.__new__(GroupWorkerRuntime) - runtime.group_id = "group-1" - runtime.plugins = [SimpleNamespace(name="sdk-demo")] - runtime.skipped_plugins = {"sdk-broken": "boom"} - runtime.issues = [ - PluginDiscoveryIssue( - severity="error", - phase="lifecycle", - plugin_id="sdk-demo", - message="on_start failed", - details="boom", - ) - ] - runtime._active_plugin_states = [ - SimpleNamespace( - plugin=SimpleNamespace(name="sdk-demo"), - loaded_plugin=SimpleNamespace( - capabilities=[], - llm_tools=[], - agents=[], - ), - ) - ] - - metadata = runtime._initialize_metadata() # noqa: SLF001 - - assert metadata["issues"] == [runtime.issues[0].to_payload()] - assert metadata["skipped_plugins"] == {"sdk-broken": "boom"} - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_supervisor_metadata_includes_discovery_issues( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - issue = PluginDiscoveryIssue( - severity="error", - phase="discovery", - plugin_id="broken", - message="插件发现失败", - details="missing runtime.python", - ) - - class _FakePeer: - def __init__(self, *args, **kwargs) -> None: - self.initialized_metadata: dict[str, object] | None = None - - def set_invoke_handler(self, handler) -> None: - self.invoke_handler = handler - - def set_cancel_handler(self, handler) -> None: - self.cancel_handler = handler - - async def start(self) -> None: - return None - - async def initialize( - self, handlers, *, provided_capabilities, metadata - ) -> None: - self.initialized_metadata = metadata - - async def stop(self) -> None: - return None - - class _FakeEnvManager: - def plan(self, plugins): - return EnvironmentPlanResult(groups=[], plugins=[], plugin_to_group={}) - - monkeypatch.setattr(supervisor_module, "Peer", _FakePeer) - monkeypatch.setattr( - supervisor_module, - "discover_plugins", - lambda _plugins_dir: PluginDiscoveryResult( - plugins=[], - skipped_plugins={"broken": "missing runtime.python"}, - issues=[issue], - ), - ) - - runtime = SupervisorRuntime( - transport=object(), - plugins_dir=tmp_path, - env_manager=_FakeEnvManager(), - ) - await runtime.start() - - assert runtime.peer.initialized_metadata is not None # type: ignore[union-attr] - assert runtime.peer.initialized_metadata["issues"] == [issue.to_payload()] # type: ignore[index,union-attr] - - await runtime.stop() - - -@pytest.mark.unit -def test_testing_helpers_mock_clock_and_environment(tmp_path: Path) -> None: - env = SDKTestEnvironment(tmp_path) - - assert env.plugins_dir == tmp_path / "plugins" - assert env.plugins_dir.exists() - assert env.plugin_dir("demo") == tmp_path / "plugins" / "demo" - - clock = MockClock(now=10.0) - assert clock.time() == 10.0 - assert clock.advance(2.5) == 12.5 - assert clock.time() == 12.5 - - -class _LimiterPlugin(Star): - async def handle(self, event: MessageEvent, ctx: Context) -> None: - await event.reply("ok") - - -async def _invoke_handler( - dispatcher: HandlerDispatcher, - *, - handler_id: str, - text: str, - request_id: str, - session_id: str = "demo:private:user-1", -) -> dict[str, object]: - message = SimpleNamespace( - id=request_id, - input={ - "handler_id": handler_id, - "event": _event_payload(text, session_id=session_id), - "args": {}, - }, - ) - return await dispatcher.invoke(message, CancelToken()) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_rate_limit_and_cooldown_behaviors() -> None: - peer = _Peer() - owner = _LimiterPlugin() - handler_id = "sdk-demo:test.handle" - - limited = LoadedHandler( - descriptor=HandlerDescriptor( - id=handler_id, - trigger=CommandTrigger(command="ping"), - ), - callable=owner.handle, - owner=owner, - plugin_id="sdk-demo", - limiter=LimiterMeta(kind="rate_limit", limit=1, window=60), - ) - dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=peer, - handlers=[limited], - ) - - await _invoke_handler( - dispatcher, handler_id=handler_id, text="ping", request_id="r1" - ) - await _invoke_handler( - dispatcher, handler_id=handler_id, text="ping", request_id="r2" - ) - - assert peer.sent_messages[0]["text"] == "ok" - assert peer.sent_messages[1]["text"] == "操作过于频繁,请稍后再试。" - - cooldown_loaded = LoadedHandler( - descriptor=HandlerDescriptor( - id="sdk-demo:test.cooldown", - trigger=CommandTrigger(command="cool"), - ), - callable=owner.handle, - owner=owner, - plugin_id="sdk-demo", - limiter=LimiterMeta( - kind="cooldown", - limit=1, - window=30, - behavior="error", - ), - ) - cooldown_dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=_Peer(), - handlers=[cooldown_loaded], - ) - - await _invoke_handler( - cooldown_dispatcher, - handler_id="sdk-demo:test.cooldown", - text="cool", - request_id="c1", - ) - with pytest.raises(AstrBotError) as exc_info: - await _invoke_handler( - cooldown_dispatcher, - handler_id="sdk-demo:test.cooldown", - text="cool", - request_id="c2", - ) - assert exc_info.value.code == ErrorCodes.COOLDOWN_ACTIVE - - -@pytest.mark.unit -def test_limiter_scope_keys_and_behavior_with_mock_clock() -> None: - clock = MockClock() - engine = LimiterEngine(clock=clock.time) - base_event = SimpleNamespace( - session_id="demo:private:user-1", - platform_id="demo", - user_id="user-1", - group_id="room-1", - ) - - assert ( - engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=LimiterMeta(kind="rate_limit", limit=1, window=60, scope="session"), - event=base_event, - ).allowed - is True - ) - session_block = engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=LimiterMeta(kind="rate_limit", limit=1, window=60, scope="session"), - event=base_event, - ) - assert session_block.allowed is False - assert session_block.hint == "操作过于频繁,请稍后再试。" - assert "sdk-demo:h:demo:private:user-1" in engine._windows # noqa: SLF001 - - assert ( - engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=LimiterMeta(kind="rate_limit", limit=1, window=60, scope="session"), - event=SimpleNamespace( - session_id="demo:private:user-2", - platform_id="demo", - user_id="user-2", - group_id="room-1", - ), - ).allowed - is True - ) - - user_engine = LimiterEngine(clock=clock.time) - user_limiter = LimiterMeta(kind="rate_limit", limit=1, window=60, scope="user") - assert ( - user_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=user_limiter, - event=base_event, - ).allowed - is True - ) - assert ( - user_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=user_limiter, - event=SimpleNamespace( - session_id="demo:private:user-9", - platform_id="demo", - user_id="user-1", - group_id="room-9", - ), - ).allowed - is False - ) - assert "sdk-demo:h:demo:user-1" in user_engine._windows # noqa: SLF001 - - group_engine = LimiterEngine(clock=clock.time) - group_limiter = LimiterMeta(kind="rate_limit", limit=1, window=60, scope="group") - assert ( - group_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=group_limiter, - event=base_event, - ).allowed - is True - ) - assert ( - group_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=group_limiter, - event=SimpleNamespace( - session_id="demo:group:room-2", - platform_id="demo", - user_id="user-2", - group_id="room-1", - ), - ).allowed - is False - ) - assert "sdk-demo:h:demo:room-1" in group_engine._windows # noqa: SLF001 - - global_engine = LimiterEngine(clock=clock.time) - global_limiter = LimiterMeta( - kind="cooldown", - limit=1, - window=30, - scope="global", - behavior="error", - ) - assert ( - global_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=global_limiter, - event=base_event, - ).allowed - is True - ) - global_block = global_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=global_limiter, - event=SimpleNamespace( - session_id="demo:private:user-2", - platform_id="demo", - user_id="user-2", - group_id="room-2", - ), - ) - assert global_block.allowed is False - assert global_block.error is not None - assert global_block.error.code == ErrorCodes.COOLDOWN_ACTIVE - assert "sdk-demo:h" in global_engine._windows # noqa: SLF001 - - silent_engine = LimiterEngine(clock=clock.time) - silent_limiter = LimiterMeta( - kind="rate_limit", - limit=1, - window=60, - scope="global", - behavior="silent", - ) - assert ( - silent_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=silent_limiter, - event=base_event, - ).allowed - is True - ) - silent_block = silent_engine.evaluate( - plugin_id="sdk-demo", - handler_id="h", - limiter=silent_limiter, - event=base_event, - ) - assert silent_block.allowed is False - assert silent_block.error is None - assert silent_block.hint is None - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_message_builder_event_helpers_and_media_helper( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[tuple[str, str]] = [] - - def _record_build(url: str, *, kind: str = "auto"): - calls.append((url, kind)) - return Image.fromURL(url) - - monkeypatch.setattr( - "astrbot_sdk.message_result.build_media_component_from_url", - _record_build, - ) - chain = ( - MessageBuilder() - .text("hello") - .at("123") - .image("https://example.com/a.png") - .build() - ) - assert isinstance(chain, MessageChain) - assert chain.plain_text(with_other_comps_mark=True) == "hello [At] [Image]" - assert calls == [("https://example.com/a.png", "image")] - - event = MessageEvent.from_payload( - { - **_event_payload("hello"), - "message_type": "group", - "group_id": "room-1", - "messages": [ - {"type": "text", "data": {"text": "hello"}}, - {"type": "at", "data": {"qq": "123"}}, - {"type": "image", "data": {"file": "https://example.com/a.png"}}, - { - "type": "file", - "data": {"name": "a.txt", "file": "https://example.com/a.txt"}, - }, - ], - } - ) - assert event.is_group_chat() is True - assert event.has_component(Image) is True - assert len(event.get_images()) == 1 - assert len(event.get_files()) == 1 - assert event.extract_plain_text() == "hello" - assert event.get_at_users() == ["123"] - - assert isinstance(await MediaHelper.from_url("https://example.com/a.png"), Image) - assert isinstance(await MediaHelper.from_url("https://example.com/a.mp3"), Record) - assert isinstance(await MediaHelper.from_url("https://example.com/a.bin"), File) - assert isinstance( - await MediaHelper.from_url("https://example.com/a.png", kind="record"), - Record, - ) - assert isinstance( - await MediaHelper.from_url("https://example.com/a.png", kind="file"), - File, - ) - assert isinstance(await MediaHelper.from_url("https://example.com/download"), File) - - with pytest.raises(AstrBotError, match="Unsupported media kind"): - await MediaHelper.from_url("https://example.com/a.png", kind="unknown") - - with pytest.raises(AstrBotError) as invalid_exc: - await MediaHelper.download("ftp://example.com/a.bin", tmp_path) - assert invalid_exc.value.code == ErrorCodes.INVALID_INPUT - - file_save_dir = tmp_path / "existing-file" - file_save_dir.write_text("x", encoding="utf-8") - with pytest.raises(AstrBotError) as internal_exc: - await MediaHelper.download("https://example.com/a.bin", file_save_dir) - assert internal_exc.value.code == ErrorCodes.INTERNAL_ERROR - - def _boom(url: str, filename: str | Path): - raise OSError("network") - - monkeypatch.setattr("astrbot_sdk.message_components.urlretrieve", _boom) - with pytest.raises(AstrBotError) as network_exc: - await MediaHelper.download("https://example.com/a.bin", tmp_path / "downloads") - assert network_exc.value.code == ErrorCodes.NETWORK_ERROR - - -class _ConversationPlugin(Star): - def __init__(self, states: list[ConversationState]) -> None: - super().__init__() - self.states = states - - async def run( - self, - event: MessageEvent, - conversation: ConversationSession, - ctx: Context, - ) -> None: - try: - answer = await conversation.ask("question?") - await conversation.reply(f"answer:{answer.text}") - finally: - self.states.append(conversation.state) - - -class _ReplaceAwareConversationPlugin(Star): - def __init__( - self, - states: list[ConversationState], - replaced_errors: list[type[Exception]], - stale_errors: list[type[Exception]], - ) -> None: - super().__init__() - self.states = states - self.replaced_errors = replaced_errors - self.stale_errors = stale_errors - - async def run( - self, - event: MessageEvent, - conversation: ConversationSession, - ctx: Context, - ) -> None: - was_replaced = False - try: - await conversation.ask("question?") - except ConversationReplaced as exc: - was_replaced = True - self.replaced_errors.append(type(exc)) - finally: - self.states.append(conversation.state) - if was_replaced: - try: - await conversation.reply("stale") - except ConversationClosed as exc: - self.stale_errors.append(type(exc)) - - -class _StickyConversationPlugin(Star): - async def run( - self, - event: MessageEvent, - conversation: ConversationSession, - ctx: Context, - ) -> None: - try: - await asyncio.sleep(3600) - except asyncio.CancelledError: - await asyncio.sleep(0.1) - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_conversation_reject_and_replace_modes() -> None: - async def _exercise( - mode: str, - ) -> tuple[HandlerDispatcher, _Peer, list[ConversationState]]: - peer = _Peer() - states: list[ConversationState] = [] - owner = _ConversationPlugin(states) - handler = LoadedHandler( - descriptor=HandlerDescriptor( - id=f"sdk-demo:test.{mode}", - trigger=CommandTrigger(command="quiz"), - ), - callable=owner.run, - owner=owner, - plugin_id="sdk-demo", - conversation=ConversationMeta( - timeout=30, - mode=mode, # type: ignore[arg-type] - busy_message="busy now", - grace_period=0.05, - ), - ) - dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=peer, - handlers=[handler], - ) - - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id=f"{mode}-1", - ) - await asyncio.sleep(0) - await asyncio.sleep(0) - - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id=f"{mode}-2", - ) - await asyncio.sleep(0) - await asyncio.sleep(0) - - waiter_message = SimpleNamespace( - id=f"{mode}-wait", - input={ - "handler_id": "__sdk_session_waiter__", - "event": _event_payload("42"), - }, - ) - await dispatcher.invoke(waiter_message, CancelToken()) - await asyncio.sleep(0) - await asyncio.sleep(0) - return dispatcher, peer, states - - reject_dispatcher, reject_peer, reject_states = await _exercise("reject") - assert [ - item["text"] for item in reject_peer.sent_messages if item["kind"] == "text" - ] == [ - "question?", - "busy now", - "answer:42", - ] - assert not reject_dispatcher._conversations # noqa: SLF001 - assert ConversationState.REPLACED not in reject_states - - replace_dispatcher, replace_peer, replace_states = await _exercise("replace") - assert [ - item["text"] for item in replace_peer.sent_messages if item["kind"] == "text" - ] == [ - "question?", - "question?", - "answer:42", - ] - assert not replace_dispatcher._conversations # noqa: SLF001 - assert ConversationState.REPLACED in replace_states - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_conversation_replace_injects_exception_and_rejects_stale_messages() -> ( - None -): - peer = _Peer() - states: list[ConversationState] = [] - replaced_errors: list[type[Exception]] = [] - stale_errors: list[type[Exception]] = [] - owner = _ReplaceAwareConversationPlugin(states, replaced_errors, stale_errors) - handler = LoadedHandler( - descriptor=HandlerDescriptor( - id="sdk-demo:test.replace-aware", - trigger=CommandTrigger(command="quiz"), - ), - callable=owner.run, - owner=owner, - plugin_id="sdk-demo", - conversation=ConversationMeta( - timeout=30, - mode="replace", - busy_message="busy now", - grace_period=0.05, - ), - ) - dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=peer, - handlers=[handler], - ) - - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id="replace-aware-1", - ) - await asyncio.sleep(0) - await asyncio.sleep(0) - - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id="replace-aware-2", - ) - await asyncio.sleep(0) - await asyncio.sleep(0) - - waiter_message = SimpleNamespace( - id="replace-aware-wait", - input={ - "handler_id": "__sdk_session_waiter__", - "event": _event_payload("42"), - }, - ) - await dispatcher.invoke(waiter_message, CancelToken()) - await asyncio.sleep(0) - await asyncio.sleep(0) - - assert replaced_errors == [ConversationReplaced] - assert stale_errors - assert all(error is ConversationClosed for error in stale_errors) - assert [item["text"] for item in peer.sent_messages if item["kind"] == "text"] == [ - "question?", - "question?", - ] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_conversation_replace_grace_timeout_only_logs_warning() -> None: - peer = _Peer() - watcher = Context(peer=peer, plugin_id="sdk-demo").logger.watch() - owner = _StickyConversationPlugin() - handler = LoadedHandler( - descriptor=HandlerDescriptor( - id="sdk-demo:test.sticky", - trigger=CommandTrigger(command="quiz"), - ), - callable=owner.run, - owner=owner, - plugin_id="sdk-demo", - conversation=ConversationMeta( - timeout=30, - mode="replace", - grace_period=0.01, - ), - ) - dispatcher = HandlerDispatcher( - plugin_id="sdk-demo", - peer=peer, - handlers=[handler], - ) - - async def _next_entry(): - return await watcher.__anext__() - - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id="sticky-1", - ) - await asyncio.sleep(0) - - pending_warning = asyncio.create_task(_next_entry()) - await _invoke_handler( - dispatcher, - handler_id=handler.descriptor.id, - text="quiz", - request_id="sticky-2", - ) - warning_entry = await pending_warning - - assert warning_entry.level == "WARNING" - assert "grace period exceeded" in warning_entry.message - - for active in list(dispatcher._conversations.values()): # noqa: SLF001 - active.task.cancel() - await asyncio.gather(active.task, return_exceptions=True) - await watcher.aclose() diff --git a/tests/test_skill_manager_sandbox_cache.py b/tests/test_skill_manager_sandbox_cache.py index 624eb6385..35fb60811 100644 --- a/tests/test_skill_manager_sandbox_cache.py +++ b/tests/test_skill_manager_sandbox_cache.py @@ -16,20 +16,6 @@ def _write_skill(root: Path, name: str, description: str) -> None: ) -def _write_sdk_registered_skill( - root: Path, - skill_name: str, - description: str, -) -> Path: - skill_dir = root / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - skill_dir.joinpath("SKILL.md").write_text( - f"---\ndescription: {description}\n---\n# {skill_name}\n", - encoding="utf-8", - ) - return skill_dir - - def test_list_skills_merges_local_and_sandbox_cache(monkeypatch, tmp_path: Path): data_dir = tmp_path / "data" temp_dir = tmp_path / "temp" @@ -169,160 +155,3 @@ def test_sandbox_and_local_path_resolution_with_show_sandbox_path_false( assert local_skill_path == skills_root / "custom-local" / "SKILL.md" assert by_name["python-sandbox"].path == "/app/skills/python-sandbox/SKILL.md" - -def test_list_skills_includes_sdk_registered_sources(monkeypatch, tmp_path: Path): - data_dir = tmp_path / "data" - temp_dir = tmp_path / "temp" - skills_root = tmp_path / "skills" - registered_root = tmp_path / "sdk_registered" - data_dir.mkdir(parents=True, exist_ok=True) - temp_dir.mkdir(parents=True, exist_ok=True) - skills_root.mkdir(parents=True, exist_ok=True) - registered_root.mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_dir), - ) - - mgr = SkillManager(skills_root=str(skills_root)) - browser_skill_dir = _write_sdk_registered_skill( - registered_root, - "browser-helper", - "sdk plugin skill", - ) - triage_skill_dir = _write_sdk_registered_skill( - registered_root, - "triage", - "sdk triage skill", - ) - mgr.replace_sdk_plugin_skills( - "sdk-demo", - [ - { - "name": "sdk-demo.browser-helper", - "description": "sdk plugin skill", - "path": str(browser_skill_dir / "SKILL.md"), - "skill_dir": str(browser_skill_dir), - }, - { - "name": "sdk-demo.triage", - "description": "sdk triage skill", - "path": str(triage_skill_dir / "SKILL.md"), - "skill_dir": str(triage_skill_dir), - }, - ], - ) - skills = mgr.list_skills(show_sandbox_path=False) - by_name = {item.name: item for item in skills} - - assert sorted(by_name) == ["sdk-demo.browser-helper", "sdk-demo.triage"] - assert by_name["sdk-demo.browser-helper"].description == "sdk plugin skill" - assert Path(by_name["sdk-demo.browser-helper"].path) == ( - browser_skill_dir / "SKILL.md" - ) - assert by_name["sdk-demo.triage"].description == "sdk triage skill" - assert Path(by_name["sdk-demo.triage"].path) == (triage_skill_dir / "SKILL.md") - - -def test_sdk_registered_skill_cannot_be_deleted(monkeypatch, tmp_path: Path): - data_dir = tmp_path / "data" - temp_dir = tmp_path / "temp" - skills_root = tmp_path / "skills" - registered_root = tmp_path / "sdk_registered" - data_dir.mkdir(parents=True, exist_ok=True) - temp_dir.mkdir(parents=True, exist_ok=True) - skills_root.mkdir(parents=True, exist_ok=True) - registered_root.mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_dir), - ) - - skill_dir = _write_sdk_registered_skill( - registered_root, - "browser-helper", - "sdk plugin skill", - ) - - mgr = SkillManager(skills_root=str(skills_root)) - mgr.replace_sdk_plugin_skills( - "sdk-demo", - [ - { - "name": "sdk-demo.browser-helper", - "description": "sdk plugin skill", - "path": str(skill_dir / "SKILL.md"), - "skill_dir": str(skill_dir), - } - ], - ) - - with pytest.raises(PermissionError): - mgr.delete_skill("sdk-demo.browser-helper") - - assert skill_dir.exists() - - -def test_remove_sdk_registered_skill_prunes_stale_sandbox_cache( - monkeypatch, - tmp_path: Path, -): - data_dir = tmp_path / "data" - temp_dir = tmp_path / "temp" - skills_root = tmp_path / "skills" - registered_root = tmp_path / "sdk_registered" - data_dir.mkdir(parents=True, exist_ok=True) - temp_dir.mkdir(parents=True, exist_ok=True) - skills_root.mkdir(parents=True, exist_ok=True) - registered_root.mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_data_path", - lambda: str(data_dir), - ) - monkeypatch.setattr( - "astrbot.core.skills.skill_manager.get_astrbot_temp_path", - lambda: str(temp_dir), - ) - - skill_dir = _write_sdk_registered_skill( - registered_root, - "browser-helper", - "sdk plugin skill", - ) - mgr = SkillManager(skills_root=str(skills_root)) - mgr.replace_sdk_plugin_skills( - "sdk-demo", - [ - { - "name": "sdk-demo.browser-helper", - "description": "sdk plugin skill", - "path": str(skill_dir / "SKILL.md"), - "skill_dir": str(skill_dir), - } - ], - ) - mgr.set_sandbox_skills_cache( - [ - { - "name": "sdk-demo.browser-helper", - "description": "sdk plugin skill", - "path": "/workspace/skills/sdk-demo.browser-helper/SKILL.md", - } - ] - ) - - mgr.remove_sdk_plugin_skills("sdk-demo") - - assert mgr.list_skills(runtime="sandbox") == [] - diff --git a/tests/unit/test_command_filter.py b/tests/unit/test_command_filter.py index 3cd269f28..3a8729cc4 100644 --- a/tests/unit/test_command_filter.py +++ b/tests/unit/test_command_filter.py @@ -1,12 +1,11 @@ +from unittest.mock import MagicMock + import pytest -import inspect -import typing -from unittest.mock import MagicMock, patch from astrbot.core.star.filter.command import ( + CommandFilter, GreedyStr, unwrap_optional, - CommandFilter, ) @@ -19,7 +18,7 @@ class TestGreedyStr: class TestUnwrapOptional: def test_optional_with_none(self): - annotation = typing.Optional[int] + annotation = int | None result = unwrap_optional(annotation) assert result == (int,) @@ -34,7 +33,7 @@ class TestUnwrapOptional: assert result == () def test_union_multiple_non_none(self): - annotation = typing.Union[int, str, float] + annotation = int | str | float result = unwrap_optional(annotation) assert int in result assert str in result @@ -185,7 +184,7 @@ class TestCommandFilter: def test_validate_and_convert_params_greedy_str_not_last(self): cf = CommandFilter(command_name="test") cf.handler_params = {"rest": GreedyStr, "extra": str} - with pytest.raises(ValueError, match="GreedyStr.*必须是最后一个参数"): + with pytest.raises(ValueError, match=r"GreedyStr.*必须是最后一个参数"): cf.validate_and_convert_params( ["arg1", "arg2"], {"rest": GreedyStr, "extra": str}, diff --git a/tests/unit/test_config_number.py b/tests/unit/test_config_number.py index 7f07e5c0f..915f3ea42 100644 --- a/tests/unit/test_config_number.py +++ b/tests/unit/test_config_number.py @@ -1,5 +1,4 @@ from unittest.mock import patch -import pytest from astrbot.core.utils.config_number import coerce_int_config @@ -87,4 +86,4 @@ class TestCoerceIntConfig: def test_complex_string_with_whitespace(self): assert coerce_int_config(" -42 ", default=0) == -42 - assert coerce_int_config("\t100\n", default=0) == 100 \ No newline at end of file + assert coerce_int_config("\t100\n", default=0) == 100 diff --git a/tests/unit/test_error_messages.py b/tests/unit/test_error_messages.py index 769600368..39023409f 100644 --- a/tests/unit/test_error_messages.py +++ b/tests/unit/test_error_messages.py @@ -1,5 +1,3 @@ -import pytest - from astrbot.core.star.error_messages import ( PLUGIN_ERROR_TEMPLATES, format_plugin_error, @@ -32,4 +30,4 @@ class TestPluginErrorMessages: def test_format_plugin_error_with_empty_kwargs(self): result = format_plugin_error("not_found_in_failed_list", **{}) - assert result == "插件不存在于失败列表中。" \ No newline at end of file + assert result == "插件不存在于失败列表中。" diff --git a/tests/unit/test_internal_agent_sub_stage.py b/tests/unit/test_internal_agent_sub_stage.py index 405b5dfd0..3b6abd095 100644 --- a/tests/unit/test_internal_agent_sub_stage.py +++ b/tests/unit/test_internal_agent_sub_stage.py @@ -113,7 +113,7 @@ async def test_internal_stage_uses_effective_runner_streaming_flag(): ), ): yielded = [] - async for item in stage.process(event, provider_wake_prefix=""): + async for item in stage.process(event): yielded.append(item) assert yielded == [None] diff --git a/tests/unit/test_path_util.py b/tests/unit/test_path_util.py index 24ceedc60..2972bb064 100644 --- a/tests/unit/test_path_util.py +++ b/tests/unit/test_path_util.py @@ -1,5 +1,3 @@ -import os -import pytest from unittest.mock import patch from astrbot.core.utils.path_util import path_Mapping @@ -77,4 +75,4 @@ class TestPathMapping: with patch("astrbot.core.utils.path_util.os.path.exists") as mock_exists: mock_exists.return_value = False result = path_Mapping(mappings, "/home/test/file.txt") - assert "/usr/local" in result \ No newline at end of file + assert "/usr/local" in result diff --git a/tests/unit/test_subagent_orchestrator.py b/tests/unit/test_subagent_orchestrator.py index 9befac887..24bc3274a 100644 --- a/tests/unit/test_subagent_orchestrator.py +++ b/tests/unit/test_subagent_orchestrator.py @@ -90,7 +90,7 @@ async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy(): [ (None, None), ([], []), - ("not-a-list", []), + ("not-a-list", None), ], ) async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):