feat(errors): Enhance AstrBotError with detailed documentation and examples

feat(events): Expand MessageEvent with reply capabilities and detailed docstrings

fix(loader): Ensure plugin path is correctly managed in sys.path

feat(star): Improve Star class documentation and lifecycle method descriptions

feat(testing): Add plugin metadata handling in MockContext and enhance PluginHarness

feat(hello): Refactor HelloPlugin to utilize new reply methods and structured capabilities

test(decorators): Add tests for input/output model support in provide_capability

test(events): Implement tests for reply_image and reply_chain methods in MessageEvent

test(http): Validate API registration with capability handler references and error handling

test(tests): Enhance tests for plugin harness and directory handling in dev commands
This commit is contained in:
whatevertogo
2026-03-14 23:37:09 +08:00
parent 6bab178d6f
commit 84184c4c0a
22 changed files with 1269 additions and 235 deletions

View File

@@ -1,42 +1,30 @@
# CLAUDE Notes
# Notes
- 2026-03-12: Legacy `handshake` payloads only contain `event_type` / `handler_full_name` metadata and do not preserve v4 command/message trigger details such as command names, aliases, keywords, or regex. Any legacy-to-v4 handshake translation must approximate handlers as coarse event subscriptions and keep the raw handshake payload in metadata for lossless fallback.
- 2026-03-12: Legacy `src/astrbot_sdk/api/event/filter.py` exported a much larger decorator surface than `src-new/astrbot_sdk/api/event/filter.py`. Current compat coverage is enough for `command` / `regex` / `permission` and the exercised migration tests, but it is not a full drop-in replacement for every historical filter helper.
- 2026-03-13: Transport-pair startup tests for `SupervisorRuntime` must start a real peer on the opposite transport and provide an `initialize` response. Wiring only the supervisor side drops or captures the outgoing initialize message without replying, and `Peer.initialize()` then waits forever.
- 2026-03-13: `CapabilityRouter.register(..., stream_handler=...)` uses the signature `(request_id, payload, cancel_token)`, not the peer-level `(message, token)`. Reusing the peer handler shape in router tests causes an immediate failed stream event before the test logic runs.
- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments.
- 2026-03-13: `astrbot_sdk.api.*` and `astrbot.*` are migration-period compat facades, not long-term primary SDK entrypoints. Keep them thin, avoid adding new runtime logic under `api/`, and prefer tightening internal imports toward top-level private compat modules or direct leaf modules.
- 2026-03-13: Legacy components are expected to share one `LegacyContext` per plugin, matching the old `StarManager` behavior. Creating one compat context per component breaks `_register_component()` / `call_context_function()` cross-component registration chains and diverges from legacy semantics.
- 2026-03-13: When checking whether a peer has finished remote initialization, avoid `getattr(mock, "remote_peer")` style probes in code that may receive `MagicMock` peers. `MagicMock` fabricates truthy child attributes, so `CapabilityProxy` should read explicit state from `peer.__dict__` or another concrete storage location instead of treating arbitrary attribute access as initialization.
- 2026-03-13: The repository has no legacy `src/astrbot_sdk/protocol` package to migrate file-for-file. `src-new/astrbot_sdk/protocol` is a v4-native protocol layer; compare it against legacy JSON-RPC behavior in `src/astrbot_sdk/runtime/*` and the maintained migration tests, not against a nonexistent old package tree.
- 2026-03-13: Old Star docs under `docs/zh/dev/star/` describe end-to-end legacy behavior, not just import surfaces. Current compat layer can import `AstrMessageEvent`, `MessageChain`, and some `filter.*` helpers, but handler result consumption is still effectively plain-text only and many documented legacy features remain absent or partial, including command groups, lifecycle/LLM hooks, session waiters, rich media helper constructors, config schema loading, and persona/provider management. Do not treat "type exists" as "old plugin behavior is compatible"; verify the runtime path end to end before declaring parity.
- 2026-03-13: Old Star docs and examples frequently import message components via `astrbot.api.message_components`, not only `astrbot.api.message`. When checking message compatibility, verify the dedicated `api.message_components` import path, legacy constructor aliases like `At(qq=...)` / `Node(uin=..., name=...)`, and helper factories such as `Image.fromURL()` before declaring the message compat surface complete.
- 2026-03-13: `api.message.components.BaseMessageComponent.to_dict()` must emit JSON-ready primitive values, not raw `Enum` members. Leaving `ComponentType` objects in payloads only looks harmless when a later JSON serializer fixes them; it breaks direct mock assertions, in-process capability routing, and any non-JSON send path.
- 2026-03-13: Keep `astrbot_sdk.runtime` root exports narrow. `Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` are reasonable advanced runtime primitives, but loader/bootstrap data structures and orchestration helpers (`LoadedPlugin`, `PluginEnvironmentManager`, `WorkerSession`, `run_supervisor`, etc.) should stay in their submodules instead of becoming accidental root-level stable API.
- 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations.
- 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree.
- 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root.
- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect.
- 2026-03-13: `Peer.initialize()` also needs to mark the peer as remotely initialized on the initiator side. Only setting `_remote_initialized` when passively receiving an inbound `InitializeMessage` makes `wait_until_remote_initialized()` a one-sided API and can deadlock callers that initialize first and then wait.
- 2026-03-13: `Peer.invoke_stream()` intentionally hides `completed` events by default, so any supervisor/bridge layer that wants to preserve a worker stream capability's final `completed.output` must opt in explicitly (for example via `include_completed=True`) or it will silently collapse the final result to an ad-hoc aggregate like `{\"items\": chunks}`.
- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail.
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
- 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal.
- 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve.
- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages.
- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run.
- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout.
- 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin.
- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks.
- 2026-03-13: Legacy package-name compatibility now has an explicit contract: keep `src-new/astrbot` as a controlled facade for old `astrbot.api.*` and selected `astrbot.core.*` paths, not a wholesale copy of the old application tree. Guard that facade with the checked-in import matrix and the external plugin matrix in `tests_v4/external_plugin_matrix.json`; do not claim compat from `load_plugin()` alone.
- 2026-03-13: Legacy AI compat methods must return `astrbot_sdk.api.provider.entities.LLMResponse`, not the v4 `clients.llm.LLMResponse`. Old plugins inspect compat fields like `completion_text`, `tools_call_name`, and `to_openai_tool_calls()`, so returning the new client model is a silent behavior regression.
- 2026-03-13: `filter.llm_tool()` must resolve deferred annotations from `from __future__ import annotations` when inferring JSON schema. Reading `inspect.Parameter.annotation` directly degrades typed params like `a: int` into `"int"` strings and silently turns tool argument schemas into generic strings.
- 2026-03-13: Legacy result hooks must reuse the same `AstrMessageEvent` instance across `on_decorating_result` and `after_message_sent`. Re-wrapping the original v4 `MessageEvent` for the second hook drops decorated `event.set_result(...)` mutations and makes post-send hooks observe an empty result.
- 2026-03-13: `src-new/astrbot_sdk/_legacy_runtime.py` already exists as the intended compat execution boundary. When cleaning runtime architecture, wire `loader` / `handler_dispatcher` / `bootstrap` through that adapter instead of adding new direct `legacy_context` branches in runtime files, or the compat logic will spread again.
- 2026-03-13: `register_legacy_component()` 只负责 compat hook / llm tool 注册,不等价于旧 `_register_component()` 的 manager/function 暴露链。不要把 loader 阶段的 legacy 组件注册误判成完整的跨组件注册表兼容。
- 2026-03-13: 不是所有 compat 都应该塞进 `_legacy_runtime.py``main.py` 识别、legacy manifest 补全、为相对导入准备 synthetic package 这些都属于 loader 阶段的兼容职责,应该放在独立的私有 loader helper 里,例如 `_legacy_loader.py`
- 2026-03-13: Real legacy plugins may still load through deep `astrbot.core.*` imports even when their public entrypoint only looks like `astrbot.api.*`. `astrbot_plugin_self_learning` hits `astrbot.core.utils.astrbot_path`, `astrbot.core.provider.*`, `astrbot.core.agent.message`, and `astrbot.core.db.po` during load; keep those deep-path shims minimal and whitelist-driven, but do not assume the `api` facade alone is enough.
- 2026-03-13: `ARCHITECTURE.md` and `refactor.md` are no longer a full source of truth for the current runtime/compat surface. The shipped code also includes `runtime.environment_groups`, `_session_waiter`, the controlled `src-new/astrbot` alias facade, compat hook execution, and extra DB capabilities such as `db.get_many` / `db.set_many` / `db.watch`. Verify architectural claims against code and tests before declaring drift or completeness.
## 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` 加载时可能串到错误模块。
---
# 开发命令
@@ -61,15 +49,7 @@ python run_tests.py -k "test_peer" # 运行匹配模式的测试
python run_tests.py --cov # 运行测试并生成覆盖率报告
```
## 重要
## 设计原则
新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践
不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。
old文件夹是兼容旧插件的测试旧插件全部放进old文件夹
- 2026-03-13: 不要再维护第二套 `_legacy/` 并行目录。private compat 以顶层 `_legacy_api.py``_legacy_runtime.py``_legacy_loader.py``_session_waiter.py``_shared_preferences.py` 为唯一实现位置,同时保留公开兼容面 `astrbot_sdk.api``astrbot_sdk.compat``src-new/astrbot` facade。
- 2026-03-14: `test_plugin/old/``test_plugin/new/` 里可能带着已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件,否则临时插件目录、断言结果和 `git status` 都可能被污染。
- 2026-03-14: grouped worker / grouped env 路径不要再复制单 worker 的 compat 生命周期和 legacy runtime 绑定逻辑。优先复用 `_legacy_runtime.py` 里的 `bind_legacy_runtime_contexts()``run_legacy_worker_startup_hooks()``run_legacy_worker_shutdown_hooks()` 以及 `resolve_plugin_lifecycle_hook()`,否则很容易出现“普通 worker 测试通过,但真正的 grouped subprocess 路径在运行时 NameError/行为漂移”的回归。
- 2026-03-14: `inspect.getmembers(module, inspect.isclass)` 会按属性名排序,所以 legacy `main.py` 组件发现若要保留声明顺序,必须遍历 `module.__dict__`;只删除后面的 `.sort()` 仍然不够。
- 2026-03-14: 如果仓库正在收敛为纯 v4 SDK删除 compat 文件前必须先移除或延迟隔离所有公开入口里的 `_legacy_*` import。`testing.py``cli.py` 里残留对 `_legacy_runtime` 的 eager import会让 `import astrbot_sdk.testing``python -m astrbot_sdk --help` 在运行前直接失败,而仅检查 site-packages 安装态的 CLI smoke test 很容易漏掉这类回归。
- 2026-03-14: 本地 `dev --watch` 或任何同一路径插件重复加载场景,不能只依赖 `import_string()` 的“跨插件模块根冲突”清理。即使模块仍属于当前插件目录,`sys.modules` 也会让 `load_plugin()` 复用旧代码;热重载前必须先按插件目录清理模块缓存。

View File

@@ -1,70 +1,31 @@
# CLAUDE Notes
## ⚠️ 兼容层弃用通知 (2026-03-14)
## v4 架构约束
**兼容层已标记为 deprecated将在下个大版本移除。**
### 运行时层
- 旧插件请使用 **AstrBot 主程序** 运行(主程序有完整的 `StarManager` 支持)
- 新插件请使用 `astrbot_sdk` 顶层入口
- 导入兼容层会触发 `DeprecationWarning`
- `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)`
**待移除的文件/目录**
- `src-new/astrbot_sdk/_legacy_*.py` - 所有 legacy 私有模块
- `src-new/astrbot_sdk/api/` - 旧版 API 兼容层
- `src-new/astrbot_sdk/compat.py` - 顶层兼容入口
- `src-new/astrbot_sdk/protocol/legacy_adapter.py` - JSON-RPC 适配器
- `src-new/astrbot/` - 旧包名别名
- `test_plugin/old/` - 旧插件示例
- `tests_v4/test_legacy*.py` - legacy 相关测试
### 模块导出约束
- 保持 `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` 加载时可能串到错误模块。
---
- 2026-03-12: Legacy `handshake` payloads only contain `event_type` / `handler_full_name` metadata and do not preserve v4 command/message trigger details such as command names, aliases, keywords, or regex. Any legacy-to-v4 handshake translation must approximate handlers as coarse event subscriptions and keep the raw handshake payload in metadata for lossless fallback.
- 2026-03-12: Legacy `src/astrbot_sdk/api/event/filter.py` exported a much larger decorator surface than `src-new/astrbot_sdk/api/event/filter.py`. Current compat coverage is enough for `command` / `regex` / `permission` and the exercised migration tests, but it is not a full drop-in replacement for every historical filter helper.
- 2026-03-13: Transport-pair startup tests for `SupervisorRuntime` must start a real peer on the opposite transport and provide an `initialize` response. Wiring only the supervisor side drops or captures the outgoing initialize message without replying, and `Peer.initialize()` then waits forever.
- 2026-03-13: `CapabilityRouter.register(..., stream_handler=...)` uses the signature `(request_id, payload, cancel_token)`, not the peer-level `(message, token)`. Reusing the peer handler shape in router tests causes an immediate failed stream event before the test logic runs.
- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments.
- 2026-03-13: `astrbot_sdk.api.*` and `astrbot.*` are migration-period compat facades, not long-term primary SDK entrypoints. Keep them thin, avoid adding new runtime logic under `api/`, and prefer tightening internal imports toward top-level private compat modules or direct leaf modules.
- 2026-03-13: Legacy components are expected to share one `LegacyContext` per plugin, matching the old `StarManager` behavior. Creating one compat context per component breaks `_register_component()` / `call_context_function()` cross-component registration chains and diverges from legacy semantics.
- 2026-03-13: When checking whether a peer has finished remote initialization, avoid `getattr(mock, "remote_peer")` style probes in code that may receive `MagicMock` peers. `MagicMock` fabricates truthy child attributes, so `CapabilityProxy` should read explicit state from `peer.__dict__` or another concrete storage location instead of treating arbitrary attribute access as initialization.
- 2026-03-13: The repository has no legacy `src/astrbot_sdk/protocol` package to migrate file-for-file. `src-new/astrbot_sdk/protocol` is a v4-native protocol layer; compare it against legacy JSON-RPC behavior in `src/astrbot_sdk/runtime/*` and the maintained migration tests, not against a nonexistent old package tree.
- 2026-03-13: Old Star docs under `docs/zh/dev/star/` describe end-to-end legacy behavior, not just import surfaces. Current compat layer can import `AstrMessageEvent`, `MessageChain`, and some `filter.*` helpers, but handler result consumption is still effectively plain-text only and many documented legacy features remain absent or partial, including command groups, lifecycle/LLM hooks, session waiters, rich media helper constructors, config schema loading, and persona/provider management. Do not treat "type exists" as "old plugin behavior is compatible"; verify the runtime path end to end before declaring parity.
- 2026-03-13: Old Star docs and examples frequently import message components via `astrbot.api.message_components`, not only `astrbot.api.message`. When checking message compatibility, verify the dedicated `api.message_components` import path, legacy constructor aliases like `At(qq=...)` / `Node(uin=..., name=...)`, and helper factories such as `Image.fromURL()` before declaring the message compat surface complete.
- 2026-03-13: `api.message.components.BaseMessageComponent.to_dict()` must emit JSON-ready primitive values, not raw `Enum` members. Leaving `ComponentType` objects in payloads only looks harmless when a later JSON serializer fixes them; it breaks direct mock assertions, in-process capability routing, and any non-JSON send path.
- 2026-03-13: Keep `astrbot_sdk.runtime` root exports narrow. `Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` are reasonable advanced runtime primitives, but loader/bootstrap data structures and orchestration helpers (`LoadedPlugin`, `PluginEnvironmentManager`, `WorkerSession`, `run_supervisor`, etc.) should stay in their submodules instead of becoming accidental root-level stable API.
- 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations.
- 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree.
- 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root.
- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect.
- 2026-03-13: `Peer.initialize()` also needs to mark the peer as remotely initialized on the initiator side. Only setting `_remote_initialized` when passively receiving an inbound `InitializeMessage` makes `wait_until_remote_initialized()` a one-sided API and can deadlock callers that initialize first and then wait.
- 2026-03-13: `Peer.invoke_stream()` intentionally hides `completed` events by default, so any supervisor/bridge layer that wants to preserve a worker stream capability's final `completed.output` must opt in explicitly (for example via `include_completed=True`) or it will silently collapse the final result to an ad-hoc aggregate like `{\"items\": chunks}`.
- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail.
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
- 2026-03-14: `test_plugin/old/` and `test_plugin/new/` may contain checked-in `__pycache__` / `*.pyc` artifacts. Any helper that copies sample plugins into temporary test directories must explicitly ignore Python bytecode caches, or fixture contents and `git status` can drift for reasons unrelated to the runtime behavior under test.
- 2026-03-14: Grouped worker / grouped environment execution must reuse the shared legacy-runtime and lifecycle helpers from `_legacy_runtime.py` and `resolve_plugin_lifecycle_hook()` instead of re-implementing them inside `runtime.bootstrap`. The grouped subprocess path can otherwise diverge from the single-worker path and fail only at runtime with missing imports/helpers even when most unit tests still pass.
- 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal.
- 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve.
- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages.
- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run.
- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout.
- 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin.
- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks.
- 2026-03-13: Treat `src-new/astrbot` as a controlled legacy facade, not as a mirror of the old `astrBot/` tree. The compat contract is the checked-in public import matrix plus the external plugin matrix in `tests_v4/external_plugin_matrix.json`; when a new deep-path shim is proposed, require both an import assertion and a real supervisor/worker plugin case before growing the facade.
- 2026-03-13: Legacy AI compat methods must return `astrbot_sdk.api.provider.entities.LLMResponse`, not the v4 `clients.llm.LLMResponse`. Old plugins inspect compat fields like `completion_text`, `tools_call_name`, and `to_openai_tool_calls()`, so returning the new client model is a silent behavior regression.
- 2026-03-13: `filter.llm_tool()` must resolve deferred annotations from `from __future__ import annotations` when inferring JSON schema. Reading `inspect.Parameter.annotation` directly degrades typed params like `a: int` into `"int"` strings and silently turns tool argument schemas into generic strings.
- 2026-03-13: Legacy result hooks must reuse the same `AstrMessageEvent` instance across `on_decorating_result` and `after_message_sent`. Re-wrapping the original v4 `MessageEvent` for the second hook drops decorated `event.set_result(...)` mutations and makes post-send hooks observe an empty result.
- 2026-03-13: `src-new/astrbot_sdk/_legacy_runtime.py` already exists as the intended compat execution boundary. When cleaning runtime architecture, wire `loader` / `handler_dispatcher` / `bootstrap` through that adapter instead of adding new direct `legacy_context` branches in runtime files, or the compat logic will spread again.
- 2026-03-13: `register_legacy_component()` only performs compat hook / tool registration via `_register_compat_component()`; it does not replicate `_register_component()` manager/function exposure. Do not treat loader-time legacy component registration as a full replacement for the old cross-component registry chain.
- 2026-03-13: Not every compat concern belongs in `_legacy_runtime.py`. Legacy `main.py` discovery, synthetic package setup for relative imports, and legacy manifest synthesis are loader-time concerns; keep those in a dedicated private loader helper such as `_legacy_loader.py` instead of mixing discovery and execution boundaries.
- 2026-03-13: Real legacy plugins may still load through deep `astrbot.core.*` imports even when their public entrypoint only looks like `astrbot.api.*`. `astrbot_plugin_self_learning` hits `astrbot.core.utils.astrbot_path`, `astrbot.core.provider.*`, `astrbot.core.agent.message`, and `astrbot.core.db.po` during load; keep those deep-path shims minimal and whitelist-driven, but do not assume the `api` facade alone is enough.
- 2026-03-13: `ARCHITECTURE.md` and `refactor.md` are no longer a full source of truth for the current runtime/compat surface. The shipped code also includes `runtime.environment_groups`, `_session_waiter`, the controlled `src-new/astrbot` alias facade, compat hook execution, and extra DB capabilities such as `db.get_many` / `db.set_many` / `db.watch`. Verify architectural claims against code and tests before declaring drift or completeness.
- 2026-03-13: Duplicating private compat logic into a second `_legacy/` package added import-order risk and architectural noise. Keep one canonical set of top-level private compat modules (`_legacy_api.py`, `_legacy_runtime.py`, `_legacy_loader.py`, `_session_waiter.py`, `_shared_preferences.py`) while preserving public `astrbot_sdk.api`, `astrbot_sdk.compat`, and `src-new/astrbot` facades.
- 2026-03-14: `inspect.getmembers(module, inspect.isclass)` sorts legacy `main.py` classes alphabetically by attribute name. Preserving old-plugin declaration order requires iterating `module.__dict__` directly; deleting a later explicit `.sort()` is insufficient.
- 2026-03-14: If the repo is being simplified to a pure v4 SDK, remove or lazily isolate every `_legacy_*` import from public entrypoints before deleting the compat files. Leaving `testing.py` or `cli.py` with eager `_legacy_runtime` imports makes `import astrbot_sdk.testing` and `python -m astrbot_sdk --help` fail immediately, and install-only entrypoint tests can miss that regression.
- 2026-03-14: `MemoryClient` 新增 `save_with_ttl()` / `get_many()` / `delete_many()` / `stats()` 方法,对应的 protocol schema 和 CapabilityRouter 处理器已同步添加。测试实现中 TTL 仅记录不实际过期,实际过期由后端实现。
- 2026-03-14: `SupervisorRuntime._register_plugin_capability()` 改进冲突处理:保留命名空间冲突直接跳过,非保留命名空间冲突自动添加插件名前缀(如 `plugin_name.capability_name`)解决。
- 2026-03-14: 本地 `dev --watch`/重复加载同一路径插件时,不能只依赖 `import_string()` 的跨插件根包冲突清理。即使缓存模块仍然属于当前插件目录,`sys.modules` 也会让 `load_plugin()` 继续复用旧代码;热重载前必须先按插件目录清掉已加载模块缓存。
# 开发命令
## 格式化与检查
@@ -88,8 +49,12 @@ python run_tests.py -k "test_peer" # 运行匹配模式的测试
python run_tests.py --cov # 运行测试并生成覆盖率报告
```
## 重要
## 设计原则
新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践
不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。
old文件夹是兼容旧插件的测试旧插件全部放进old文件夹
---
# currentDate
Today's date is 2026-03-14.

View File

@@ -30,19 +30,19 @@ my_plugin/
### 2. 校验插件
```bash
astrbot-sdk validate --plugin-dir .
astrbot-sdk validate
```
### 3. 本地运行一次
```bash
astrbot-sdk dev --local --plugin-dir . --event-text hello
astrbot-sdk dev --local --event-text hello
```
### 4. 开启热重载
```bash
astrbot-sdk dev --local --watch --plugin-dir . --event-text hello
astrbot-sdk dev --local --watch --event-text hello
```
保存 `main.py` 后,本地 harness 会自动重载并重新派发这条消息。
@@ -79,6 +79,8 @@ components:
- `MessageEvent.text`: 当前消息文本
- `MessageEvent.reply(text)`: 回复文本
- `MessageEvent.reply_image(image_url)`: 回复图片
- `MessageEvent.reply_chain(chain)`: 回复消息链
- `Context.plugin_id`: 当前插件 ID
- `Context.logger`: 已绑定插件 ID 的日志器
@@ -104,22 +106,27 @@ components:
- `ctx.metadata.get_current_plugin()`
- `ctx.metadata.get_plugin_config()`
- `ctx.http.register_api(...)`
- `ctx.http.register_api(handler=self.http_handler)`
- `ctx.http.unregister_api(...)`
- `ctx.http.list_apis()`
### 自定义 capability
- `@provide_capability(..., input_model=MyInput, output_model=MyOutput)`
- `ctx.http.register_api(handler=self.http_handler)` 可以直接复用上面的 capability 方法引用
## 本地调试
### 单次派发
```bash
astrbot-sdk dev --local --plugin-dir . --event-text hello
astrbot-sdk dev --local --event-text hello
```
### 交互模式
```bash
astrbot-sdk dev --local --plugin-dir . --interactive
astrbot-sdk dev --local --interactive
```
交互模式支持:
@@ -135,7 +142,7 @@ astrbot-sdk dev --local --plugin-dir . --interactive
### 热重载
```bash
astrbot-sdk dev --local --watch --plugin-dir . --interactive
astrbot-sdk dev --local --watch --interactive
```
适合边改边测。代码变更后会自动重建插件运行时。
@@ -169,6 +176,21 @@ async def test_hello_handler():
python -m pytest tests/test_plugin.py -v
```
如果你想走真实 dispatch 链,而不是直接调用 handler
```python
from pathlib import Path
from astrbot_sdk.testing import PluginHarness
async def test_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)
```
## 示例插件
面向插件作者的最小示例在:

View File

@@ -18,6 +18,7 @@ hello_plugin/
- 如何定义一个 `Star` 插件
- 如何注册命令 handler
- 如何使用 `MessageEvent.reply()`
- 如何用 `PluginHarness.from_plugin_dir()` 走真实 dispatch 链
- 如何从 `Context` 里读取当前插件元数据
- 如何用 `MockContext` / `MockMessageEvent` 写插件测试
@@ -26,9 +27,10 @@ hello_plugin/
在仓库根目录执行:
```bash
astrbot-sdk validate --plugin-dir examples/hello_plugin
astrbot-sdk dev --local --plugin-dir examples/hello_plugin --event-text hello
astrbot-sdk dev --local --watch --plugin-dir examples/hello_plugin --event-text hello
cd examples/hello_plugin
astrbot-sdk validate
astrbot-sdk dev --local --event-text hello
astrbot-sdk dev --local --watch --event-text hello
```
## 测试
@@ -41,3 +43,5 @@ python -m pytest examples/hello_plugin/tests/test_plugin.py -v
- `hello`: 最小命令,收到 `hello` 时回复 `Hello, World!`
- `about`: 读取 `ctx.metadata.get_current_plugin()`,演示 capability 客户端的基础用法
- `tests/test_plugin.py`: 展示 direct handler test
- `tests/test_dispatch.py`: 展示 `PluginHarness.from_plugin_dir()` dispatch test

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
SRC_NEW = REPO_ROOT / "src-new"
if str(SRC_NEW) not in sys.path:
sys.path.insert(0, str(SRC_NEW))

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from astrbot_sdk.testing import PluginHarness
@pytest.mark.asyncio
async def test_dispatch_hello_command() -> None:
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)

View File

@@ -1,13 +1,35 @@
import importlib.util
from pathlib import Path
import pytest
from astrbot_sdk.testing import MockContext, MockMessageEvent
from main import HelloPlugin
PLUGIN_DIR = Path(__file__).resolve().parents[1]
def _load_plugin_class():
module_path = PLUGIN_DIR / "main.py"
spec = importlib.util.spec_from_file_location(
"examples_hello_plugin_main",
module_path,
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.HelloPlugin
HelloPlugin = _load_plugin_class()
@pytest.mark.asyncio
async def test_hello_handler() -> None:
plugin = HelloPlugin()
ctx = MockContext(plugin_id="hello_plugin")
ctx = MockContext(
plugin_id="hello_plugin",
plugin_metadata={"display_name": "Hello Plugin"},
)
event = MockMessageEvent(text="/hello", context=ctx)
await plugin.hello(event, ctx)
@@ -19,9 +41,12 @@ async def test_hello_handler() -> None:
@pytest.mark.asyncio
async def test_about_handler() -> None:
plugin = HelloPlugin()
ctx = MockContext(plugin_id="hello_plugin")
ctx = MockContext(
plugin_id="hello_plugin",
plugin_metadata={"display_name": "Hello Plugin"},
)
event = MockMessageEvent(text="/about", context=ctx)
await plugin.about(event, ctx)
assert any("hello_plugin" in reply for reply in event.replies)
assert any("Hello Plugin" in reply for reply in event.replies)

View File

@@ -1,4 +1,22 @@
"""跨任务传播插件调用者身份"""
"""插件调用者身份上下文管理。
本模块使用 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
@@ -6,6 +24,7 @@ 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,
@@ -13,20 +32,53 @@ _CALLER_PLUGIN_ID: ContextVar[str | None] = ContextVar(
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

View File

@@ -253,7 +253,6 @@ class _ReloadableLocalDevRunner:
state: dict[str, Any],
plugin_load_error: type[Exception],
plugin_execution_error: type[Exception],
local_runtime_config,
plugin_harness,
stdout_platform_sink,
) -> None:
@@ -261,7 +260,6 @@ class _ReloadableLocalDevRunner:
self.state = state
self._plugin_load_error = plugin_load_error
self._plugin_execution_error = plugin_execution_error
self._local_runtime_config = local_runtime_config
self._plugin_harness = plugin_harness
self._stdout_platform_sink = stdout_platform_sink
self._harness = None
@@ -274,15 +272,13 @@ class _ReloadableLocalDevRunner:
async def reload(self) -> bool:
async with self._lock:
await self._stop_harness()
harness = self._plugin_harness(
self._local_runtime_config(
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"]),
),
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:
@@ -433,7 +429,6 @@ async def _run_local_dev(
max_watch_reloads: int | None = None,
) -> None:
from .testing import (
LocalRuntimeConfig,
PluginHarness,
StdoutPlatformSink,
_PluginExecutionError,
@@ -453,7 +448,6 @@ async def _run_local_dev(
state=state,
plugin_load_error=_PluginLoadError,
plugin_execution_error=_PluginExecutionError,
local_runtime_config=LocalRuntimeConfig,
plugin_harness=PluginHarness,
stdout_platform_sink=StdoutPlatformSink,
)
@@ -467,15 +461,13 @@ async def _run_local_dev(
return
sink = StdoutPlatformSink(stream=sys.stdout)
harness = PluginHarness(
LocalRuntimeConfig(
plugin_dir=plugin_dir,
session_id=session_id,
user_id=user_id,
platform=platform,
group_id=group_id,
event_type=event_type,
),
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:
@@ -620,9 +612,9 @@ def _render_init_readme(*, plugin_name: str) -> str:
## 本地开发
```bash
astrbot-sdk validate --plugin-dir .
astrbot-sdk dev --local --plugin-dir . --event-text hello
astrbot-sdk dev --local --watch --plugin-dir . --event-text hello
astrbot-sdk validate
astrbot-sdk dev --local --event-text hello
astrbot-sdk dev --local --watch --event-text hello
```
## 运行测试
@@ -638,22 +630,37 @@ 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
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}")
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)
'''
)
@@ -665,6 +672,18 @@ def _ensure_plugin_dir_exists(plugin_dir: Path) -> Path:
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)
@@ -863,9 +882,10 @@ def build(plugin_dir: Path, output_dir: Path | None) -> None:
@cli.command()
@click.option(
"--plugin-dir",
required=True,
required=False,
default=None,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to run locally",
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(
@@ -887,7 +907,7 @@ def build(plugin_dir: Path, output_dir: Path | None) -> None:
@click.option("--group-id", default=None)
@click.option("--event-type", default="message", show_default=True)
def dev(
plugin_dir: Path,
plugin_dir: Path | None,
local_mode: bool,
standalone_mode: bool,
event_text: str | None,
@@ -906,9 +926,10 @@ def dev(
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=plugin_dir,
plugin_dir=resolved_plugin_dir,
event_text=event_text,
interactive=interactive,
watch=watch,
@@ -918,9 +939,9 @@ def dev(
group_id=group_id,
event_type=event_type,
),
log_message=f"启动本地开发模式:{plugin_dir}",
log_message=f"启动本地开发模式:{resolved_plugin_dir}",
context={
"plugin_dir": plugin_dir,
"plugin_dir": resolved_plugin_dir,
"session_id": session_id,
"platform": platform_name,
"event_type": event_type,

View File

@@ -40,6 +40,34 @@ from __future__ import annotations
from typing import Any
from ._proxy import CapabilityProxy
from ..decorators import get_capability_meta
from ..errors import AstrBotError
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:
@@ -62,7 +90,9 @@ class HTTPClient:
async def register_api(
self,
route: str,
handler_capability: str,
handler_capability: str | None = None,
*,
handler: Any | None = None,
methods: list[str] | None = None,
description: str = "",
) -> None:
@@ -71,6 +101,7 @@ class HTTPClient:
Args:
route: API 路由路径(如 "/my-api"
handler_capability: 处理此路由的 capability 名称
handler: 使用 @provide_capability 标记的方法引用
methods: HTTP 方法列表,默认 ["GET"]
description: API 描述
@@ -84,13 +115,14 @@ class HTTPClient:
"""
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": handler_capability,
"handler_capability": resolved_handler,
"description": description,
},
)

View File

@@ -1,6 +1,21 @@
"""v4 原生运行时上下文。
`Context` 负责组合 v4 原生 capability 客户端。
`Context` 是插件与 AstrBot Core 交互的主要入口,
负责组合所有 capability 客户端并提供统一的访问接口。
每个 handler 调用都会创建一个新的 Context 实例,
绑定到当前的 Peer、插件 ID 和取消令牌。
Attributes:
llm: LLM 能力客户端,用于 AI 对话
memory: 记忆能力客户端,用于语义存储
db: 数据库客户端,用于 KV 持久化
platform: 平台客户端,用于发送消息
http: HTTP 客户端,用于注册 API 端点
metadata: 元数据客户端,用于查询插件信息
plugin_id: 当前插件的唯一标识
logger: 绑定了插件 ID 的日志器
cancel_token: 取消令牌,用于处理请求取消
"""
from __future__ import annotations
@@ -24,27 +39,65 @@ from .clients._proxy import CapabilityProxy
@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: 平台客户端
http: HTTP 客户端
metadata: 元数据客户端
plugin_id: 当前插件 ID
logger: 日志器
cancel_token: 取消令牌
"""
def __init__(
self,
*,
@@ -53,6 +106,14 @@ class Context:
cancel_token: CancelToken | None = None,
logger: 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)
self.peer = peer
self.llm = LLMClient(proxy)

View File

@@ -1,12 +1,37 @@
"""v4 原生装饰器。
迁移期适配入口位于独立模块;这里仅保留 v4 原生 trigger/permission 元数据建模
提供声明式的方法来注册 handler 和 capability
装饰器会在方法上附加元数据,由 Star.__init_subclass__ 自动收集。
可用的装饰器:
- @on_command: 命令触发器
- @on_message: 消息触发器(关键词/正则)
- @on_event: 事件触发器
- @on_schedule: 定时任务触发器
- @require_admin: 权限标记
- @provide_capability: 声明对外暴露的能力
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
from dataclasses import dataclass, field
from typing import Any, Callable
from typing import Any, Callable, cast
from pydantic import BaseModel
from .protocol.descriptors import (
CapabilityDescriptor,
@@ -25,6 +50,18 @@ CAPABILITY_META_ATTR = "__astrbot_capability_meta__"
@dataclass(slots=True)
class HandlerMeta:
"""Handler 元数据。
存储在方法上的 __astrbot_handler_meta__ 属性中。
Attributes:
trigger: 触发器(命令/消息/事件/定时)
kind: handler 类型标识
contract: 契约类型(可选)
priority: 执行优先级(数值越大越先执行)
permissions: 权限要求
"""
trigger: CommandTrigger | MessageTrigger | EventTrigger | ScheduleTrigger | None = (
None
)
@@ -36,10 +73,19 @@ class HandlerMeta:
@dataclass(slots=True)
class CapabilityMeta:
"""Capability 元数据。
存储在方法上的 __astrbot_capability_meta__ 属性中。
Attributes:
descriptor: 能力描述符
"""
descriptor: CapabilityDescriptor
def _get_or_create_meta(func: HandlerCallable) -> HandlerMeta:
"""获取或创建 handler 元数据。"""
meta = getattr(func, HANDLER_META_ATTR, None)
if meta is None:
meta = HandlerMeta()
@@ -48,19 +94,78 @@ def _get_or_create_meta(func: HandlerCallable) -> HandlerMeta:
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 _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,
*,
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)
"""
def decorator(func: HandlerCallable) -> HandlerCallable:
meta = _get_or_create_meta(func)
meta.trigger = CommandTrigger(
@@ -79,6 +184,31 @@ def on_message(
keywords: list[str] | None = None,
platforms: list[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(
@@ -92,6 +222,23 @@ def on_message(
def on_event(event_type: str) -> 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)
@@ -105,6 +252,30 @@ def on_schedule(
cron: str | None = None,
interval_seconds: int | 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)
@@ -114,6 +285,22 @@ def on_schedule(
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
@@ -125,19 +312,63 @@ def provide_capability(
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。"""
"""声明插件对外暴露的 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,
output_schema=output_schema,
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,
)

View File

@@ -1,4 +1,29 @@
"""跨运行时边界传递的统一错误模型。"""
"""跨运行时边界传递的统一错误模型。
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
@@ -6,11 +31,19 @@ from dataclasses import dataclass
class ErrorCodes:
"""AstrBot v4 的稳定错误码常量。"""
"""AstrBot v4 的稳定错误码常量。
这些错误码在协议层稳定,不应随意更改。
新增错误码应放在对应分类的末尾。
分类:
- 不可重试错误retryable=False配置错误、权限错误等
- 可重试错误retryable=True网络超时、临时故障等
"""
UNKNOWN_ERROR = "unknown_error"
# retryable = False
# 不可重试错误 - 配置或使用问题
LLM_NOT_CONFIGURED = "llm_not_configured"
CAPABILITY_NOT_FOUND = "capability_not_found"
PERMISSION_DENIED = "permission_denied"
@@ -21,7 +54,7 @@ class ErrorCodes:
PROTOCOL_ERROR = "protocol_error"
INTERNAL_ERROR = "internal_error"
# retryable = True
# 可重试错误 - 临时故障
CAPABILITY_TIMEOUT = "capability_timeout"
NETWORK_ERROR = "network_error"
LLM_TEMPORARY_ERROR = "llm_temporary_error"
@@ -29,6 +62,29 @@ class ErrorCodes:
@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 = ""
@@ -39,6 +95,14 @@ class AstrBotError(Exception):
@classmethod
def cancelled(cls, message: str = "调用被取消") -> "AstrBotError":
"""创建取消错误。
Args:
message: 错误消息
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.CANCELLED,
message=message,
@@ -48,6 +112,14 @@ class AstrBotError(Exception):
@classmethod
def capability_not_found(cls, name: str) -> "AstrBotError":
"""创建能力未找到错误。
Args:
name: 未找到的能力名称
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.CAPABILITY_NOT_FOUND,
message=f"未找到能力:{name}",
@@ -62,6 +134,15 @@ class AstrBotError(Exception):
*,
hint: str = "请检查调用参数",
) -> "AstrBotError":
"""创建输入无效错误。
Args:
message: 详细错误消息
hint: 用户提示
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.INVALID_INPUT,
message=message,
@@ -71,6 +152,14 @@ class AstrBotError(Exception):
@classmethod
def protocol_version_mismatch(cls, message: str) -> "AstrBotError":
"""创建协议版本不匹配错误。
Args:
message: 详细错误消息
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.PROTOCOL_VERSION_MISMATCH,
message=message,
@@ -80,6 +169,14 @@ class AstrBotError(Exception):
@classmethod
def protocol_error(cls, message: str) -> "AstrBotError":
"""创建协议错误。
Args:
message: 详细错误消息
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.PROTOCOL_ERROR,
message=message,
@@ -94,6 +191,15 @@ class AstrBotError(Exception):
*,
hint: str = "请联系插件作者",
) -> "AstrBotError":
"""创建内部错误。
Args:
message: 详细错误消息
hint: 用户提示
Returns:
AstrBotError 实例
"""
return cls(
code=ErrorCodes.INTERNAL_ERROR,
message=message,
@@ -102,6 +208,13 @@ class AstrBotError(Exception):
)
def to_payload(self) -> dict[str, object]:
"""序列化为可传输的字典格式。
用于跨进程传递错误信息。
Returns:
包含错误信息的字典
"""
return {
"code": self.code,
"message": self.message,
@@ -111,6 +224,14 @@ class AstrBotError(Exception):
@classmethod
def from_payload(cls, payload: dict[str, object]) -> "AstrBotError":
"""从字典反序列化错误实例。
Args:
payload: 包含错误信息的字典
Returns:
AstrBotError 实例
"""
return cls(
code=str(payload.get("code", ErrorCodes.UNKNOWN_ERROR)),
message=str(payload.get("message", "未知错误")),

View File

@@ -2,6 +2,12 @@
顶层 ``MessageEvent`` 保持精简,只承载 v4 运行时真正需要的基础能力。
迁移期扩展事件能力放在独立模块中,而不是继续塞回顶层事件类型。
MessageEvent 是 handler 接收的主要事件类型,封装了:
- 消息文本内容
- 发送者信息user_id, group_id
- 平台标识
- 回复能力reply, reply_image, reply_chain
"""
from __future__ import annotations
@@ -18,6 +24,11 @@ if TYPE_CHECKING:
@dataclass(slots=True)
class PlainTextResult:
"""纯文本结果。
用于 handler 返回简单的文本结果。
"""
text: str
@@ -25,6 +36,25 @@ ReplyHandler = Callable[[str], Awaitable[None]]
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}")
"""
def __init__(
self,
*,
@@ -37,6 +67,18 @@ class MessageEvent:
context: "Context | None" = None,
reply_handler: ReplyHandler | None = None,
) -> None:
"""初始化消息事件。
Args:
text: 消息文本
user_id: 用户 ID
group_id: 群组 ID
platform: 平台标识
session_id: 会话 IDNone 时自动从 group_id/user_id 推断
raw: 原始消息数据
context: 运行时上下文
reply_handler: 自定义回复处理器
"""
self.text = text
self.user_id = user_id
self.group_id = group_id
@@ -51,6 +93,16 @@ class MessageEvent:
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,
@@ -59,6 +111,16 @@ class MessageEvent:
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")
@@ -78,6 +140,11 @@ class MessageEvent:
)
def to_payload(self) -> dict[str, Any]:
"""转换为协议载荷格式。
Returns:
可序列化的字典
"""
payload = dict(self.raw)
payload.update(
{
@@ -94,6 +161,11 @@ class MessageEvent:
@property
def session_ref(self) -> SessionRef | None:
"""获取会话引用对象。
Returns:
SessionRef 实例,如果没有有效的 session_id 则返回 None
"""
if not self.session_id:
return None
return SessionRef(
@@ -104,15 +176,61 @@ class MessageEvent:
@property
def target(self) -> SessionRef | None:
"""session_ref 的别名。"""
return self.session_ref
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: 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)
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)

View File

@@ -712,8 +712,8 @@ def _prepare_plugin_import(module_name: str, plugin_dir: Path | None) -> None:
plugin_root = plugin_dir.resolve()
plugin_path = str(plugin_root)
if plugin_path not in sys.path:
sys.path.insert(0, plugin_path)
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):

View File

@@ -1,6 +1,28 @@
"""v4 原生插件基类。
迁移期补充类型位于独立模块;这里仅承载 v4 插件生命周期与 handler 收集逻辑
所有 v4 插件都应继承 `Star` 类,并通过装饰器声明 handler。
框架会自动收集带有 @on_command、@on_message 等装饰器的方法。
生命周期:
1. 插件加载时__init_subclass__ 收集所有 handler 方法名
2. 插件启动时,调用 on_start() 进行初始化
3. 收到消息时,调用匹配的 handler 方法
4. handler 出错时,调用 on_error() 处理异常
5. 插件卸载时,调用 on_stop() 进行清理
Example:
class MyPlugin(Star):
@on_command("hello")
async def hello(self, event: MessageEvent, ctx: Context):
await event.reply("Hello!")
async def on_start(self, ctx):
# 初始化资源
pass
async def on_stop(self, ctx):
# 清理资源
pass
"""
from __future__ import annotations
@@ -14,9 +36,28 @@ from .errors import AstrBotError
class Star:
"""v4 原生插件基类。
所有插件都应继承此类。子类可以使用装饰器声明 handler
框架会自动收集并注册。
Class Attributes:
__handlers__: 收集到的 handler 方法名元组
Lifecycle Methods:
on_start: 插件启动时调用
on_stop: 插件停止时调用
on_error: handler 执行出错时调用
"""
__handlers__: tuple[str, ...] = ()
def __init_subclass__(cls, **kwargs: Any) -> None:
"""收集子类中所有带有 handler 装饰器的方法。
遍历类的 MRO收集所有标记了 __astrbot_handler_meta__ 的方法。
使用 dict 去重保证每个方法名只出现一次。
"""
super().__init_subclass__(**kwargs)
from .decorators import get_handler_meta
@@ -30,12 +71,48 @@ class Star:
cls.__handlers__ = tuple(handlers.keys())
async def on_start(self, ctx: Any | None = None) -> None:
"""插件启动时的初始化钩子。
在插件首次加载或重新加载时调用。
可用于初始化数据库连接、加载配置等。
Args:
ctx: 运行时上下文(可选)
Note:
子类可以重写此方法以执行初始化逻辑
"""
return None
async def on_stop(self, ctx: Any | None = None) -> None:
"""插件停止时的清理钩子。
在插件卸载或重新加载前调用。
可用于关闭连接、保存状态等。
Args:
ctx: 运行时上下文(可选)
Note:
子类可以重写此方法以执行清理逻辑
"""
return None
async def on_error(self, error: Exception, event, ctx) -> None:
"""handler 执行出错时的错误处理钩子。
默认行为:
- AstrBotError: 根据 retryable/hint/message 生成回复
- 其他异常: 返回通用错误消息
Args:
error: 捕获的异常
event: 触发 handler 的事件对象
ctx: 运行时上下文
Note:
子类可以重写此方法以自定义错误处理逻辑
"""
if isinstance(error, AstrBotError):
if error.retryable:
await event.reply("请求失败,请稍后重试")
@@ -49,4 +126,11 @@ class Star:
@classmethod
def __astrbot_is_new_star__(cls) -> bool:
"""标识这是 v4 原生 Star 类。
用于区分 v4 插件和 legacy 插件。
Returns:
总是返回 True
"""
return True

View File

@@ -20,7 +20,7 @@ import shlex
import typing
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TextIO, get_type_hints
from typing import Any, Mapping, TextIO, get_type_hints
from .context import CancelToken, Context as RuntimeContext
from .errors import AstrBotError
@@ -421,6 +421,31 @@ def _plugin_metadata_from_spec(
}
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)),
}
class MockContext(RuntimeContext):
"""直接用于 handler 单元测试的轻量运行时上下文。"""
@@ -431,6 +456,7 @@ class MockContext(RuntimeContext):
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)
@@ -442,14 +468,7 @@ class MockContext(RuntimeContext):
logger=logger,
)
self.router.upsert_plugin(
metadata={
"name": plugin_id,
"display_name": plugin_id,
"description": "",
"author": "",
"version": "0.0.0",
"enabled": True,
},
metadata=_normalize_plugin_metadata(plugin_id, plugin_metadata),
config={},
)
self.llm = MockLLMClient(self.llm, self.router)
@@ -545,6 +564,30 @@ class PluginHarness:
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

View File

@@ -30,6 +30,8 @@ from __future__ import annotations
import asyncio
from pydantic import BaseModel, Field
from astrbot_sdk import (
Context,
MessageEvent,
@@ -44,6 +46,33 @@ from astrbot_sdk import (
from astrbot_sdk.context import CancelToken
class EchoCapabilityInput(BaseModel):
text: str
class EchoCapabilityOutput(BaseModel):
echo: str
plugin_id: str
class StreamCapabilityInput(BaseModel):
text: str
class StreamCapabilityOutput(BaseModel):
items: list[dict[str, object]]
class HttpHandlerInput(BaseModel):
method: str = "GET"
body: dict[str, object] = Field(default_factory=dict)
class HttpHandlerOutput(BaseModel):
status: int
body: dict[str, object]
class HelloPlugin(Star):
"""Representative v4 plugin fixture covering all SDK capabilities."""
@@ -210,12 +239,11 @@ class HelloPlugin(Star):
# Send text
await ctx.platform.send(target, f"成员数: {len(members)}")
# Send image
await ctx.platform.send_image(target, "https://example.com/demo.png")
# Send image back to the current conversation
await event.reply_image("https://example.com/demo.png")
# Send message chain
await ctx.platform.send_chain(
target,
# Send message chain back to the current conversation
await event.reply_chain(
[
{"type": "Plain", "text": "消息链 "},
{"type": "Image", "file": "https://example.com/demo.png"},
@@ -225,8 +253,7 @@ class HelloPlugin(Star):
@on_command("announce", description="发送富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
"""Send rich message chain."""
await ctx.platform.send_chain(
event.target or event.session_id,
await event.reply_chain(
[
{"type": "Plain", "text": "公告: "},
{"type": "Plain", "text": event.text or "无内容"},
@@ -242,7 +269,7 @@ class HelloPlugin(Star):
"""Register a custom HTTP API endpoint."""
await ctx.http.register_api(
route="/demo/api",
handler_capability="demo.http_handler",
handler=self.http_handler_capability,
methods=["GET", "POST"],
description="Demo HTTP API",
)
@@ -345,19 +372,8 @@ class HelloPlugin(Star):
@provide_capability(
"demo.echo",
description="回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"echo": {"type": "string"},
"plugin_id": {"type": "string"},
},
"required": ["echo", "plugin_id"],
},
input_model=EchoCapabilityInput,
output_model=EchoCapabilityOutput,
)
async def echo_capability(
self,
@@ -377,21 +393,8 @@ class HelloPlugin(Star):
@provide_capability(
"demo.stream",
description="流式回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object"},
}
},
"required": ["items"],
},
input_model=StreamCapabilityInput,
output_model=StreamCapabilityOutput,
supports_stream=True,
cancelable=True,
)
@@ -412,20 +415,8 @@ class HelloPlugin(Star):
@provide_capability(
"demo.http_handler",
description="处理 /demo/api HTTP 请求",
input_schema={
"type": "object",
"properties": {
"method": {"type": "string"},
"body": {"type": "object"},
},
},
output_schema={
"type": "object",
"properties": {
"status": {"type": "integer"},
"body": {"type": "object"},
},
},
input_model=HttpHandlerInput,
output_model=HttpHandlerOutput,
)
async def http_handler_capability(
self,

View File

@@ -5,6 +5,7 @@ Tests for decorators.py - Handler decorator infrastructure.
from __future__ import annotations
import pytest
from pydantic import BaseModel
from astrbot_sdk.decorators import (
get_capability_meta,
@@ -295,6 +296,62 @@ class TestProvideCapabilityDecorator:
async def reserved(payload):
return payload
def test_supports_input_output_models(self):
"""@provide_capability should accept pydantic models and derive schemas."""
class EchoInput(BaseModel):
text: str
class EchoOutput(BaseModel):
echoed: str
@provide_capability(
"demo.typed",
description="typed capability",
input_model=EchoInput,
output_model=EchoOutput,
)
async def typed(payload):
return payload
meta = get_capability_meta(typed)
assert meta is not None
assert meta.descriptor.input_schema["properties"]["text"]["type"] == "string"
assert meta.descriptor.output_schema["properties"]["echoed"]["type"] == "string"
def test_rejects_schema_and_model_conflicts(self):
"""@provide_capability should reject mixed schema/model declarations."""
class EchoInput(BaseModel):
text: str
with pytest.raises(
ValueError, match="input_schema 和 input_model 不能同时提供"
):
@provide_capability(
"demo.conflict",
description="conflict capability",
input_schema={"type": "object"},
input_model=EchoInput,
)
async def conflict(payload):
return payload
def test_rejects_non_pydantic_models(self):
"""@provide_capability should require BaseModel subclasses."""
with pytest.raises(
TypeError, match="input_model 必须是 pydantic BaseModel 子类"
):
@provide_capability(
"demo.invalid_model",
description="invalid model",
input_model=dict,
)
async def invalid(payload):
return payload
def test_can_combine_with_other_decorators(self):
"""@require_admin can be combined with other decorators."""

View File

@@ -7,6 +7,7 @@ from __future__ import annotations
import pytest
from astrbot_sdk.events import MessageEvent, PlainTextResult
from astrbot_sdk.testing import MockContext
class TestMessageEvent:
@@ -84,6 +85,55 @@ class TestMessageEvent:
with pytest.raises(RuntimeError, match="未绑定 reply handler"):
await event.reply("response")
@pytest.mark.asyncio
async def test_reply_image_uses_bound_runtime_context(self):
ctx = MockContext(plugin_id="demo")
event = MessageEvent(
text="hello", session_id="s1", platform="test", context=ctx
)
await event.reply_image("https://example.com/demo.png")
assert ctx.sent_messages[0].kind == "image"
assert ctx.sent_messages[0].image_url == "https://example.com/demo.png"
@pytest.mark.asyncio
async def test_reply_chain_uses_structured_target(self):
ctx = MockContext(plugin_id="demo")
event = MessageEvent.from_payload(
{
"text": "hello",
"target": {
"conversation_id": "session-1",
"platform": "test-platform",
},
},
context=ctx,
)
await event.reply_chain([{"type": "Plain", "text": "hi"}])
assert ctx.sent_messages[0].kind == "chain"
assert ctx.sent_messages[0].session_id == "session-1"
assert ctx.sent_messages[0].target == {
"conversation_id": "session-1",
"platform": "test-platform",
"raw": {
"text": "hello",
"target": {
"conversation_id": "session-1",
"platform": "test-platform",
},
},
}
@pytest.mark.asyncio
async def test_reply_image_without_runtime_context_raises(self):
event = MessageEvent(text="hello", session_id="s1")
with pytest.raises(RuntimeError, match="未绑定运行时上下文"):
await event.reply_image("https://example.com/demo.png")
def test_to_payload(self):
"""to_payload() should serialize event to dict."""
event = MessageEvent(

View File

@@ -9,6 +9,8 @@ import pytest
from astrbot_sdk.clients.http import HTTPClient
from astrbot_sdk.clients.metadata import MetadataClient, PluginMetadata
from astrbot_sdk.clients._proxy import CapabilityProxy
from astrbot_sdk.decorators import provide_capability
from astrbot_sdk.errors import AstrBotError
class TestHTTPClient:
@@ -59,6 +61,74 @@ class TestHTTPClient:
call_args = mock_proxy.call.call_args
assert call_args[0][1]["methods"] == ["GET"]
@pytest.mark.asyncio
async def test_register_api_accepts_capability_handler_reference(
self, http_client, mock_proxy
):
class DemoPlugin:
@provide_capability(
"demo.http_handler",
description="handle http requests",
)
async def http_handler(self, payload):
return payload
plugin = DemoPlugin()
await http_client.register_api(
route="/test-api",
handler=plugin.http_handler,
methods=["POST"],
)
mock_proxy.call.assert_called_once_with(
"http.register_api",
{
"route": "/test-api",
"methods": ["POST"],
"handler_capability": "demo.http_handler",
"description": "",
},
)
@pytest.mark.asyncio
async def test_register_api_rejects_conflicting_handler_inputs(
self, http_client, mock_proxy
):
class DemoPlugin:
@provide_capability(
"demo.http_handler",
description="handle http requests",
)
async def http_handler(self, payload):
return payload
plugin = DemoPlugin()
with pytest.raises(AstrBotError, match="不能同时提供"):
await http_client.register_api(
route="/test-api",
handler_capability="demo.http_handler",
handler=plugin.http_handler,
)
mock_proxy.call.assert_not_called()
@pytest.mark.asyncio
async def test_register_api_rejects_non_capability_handler(
self, http_client, mock_proxy
):
class DemoPlugin:
async def plain_method(self, payload):
return payload
plugin = DemoPlugin()
with pytest.raises(
AstrBotError, match="需要传入使用 @provide_capability 声明的方法"
):
await http_client.register_api(
route="/test-api",
handler=plugin.plain_method,
)
mock_proxy.call.assert_not_called()
@pytest.mark.asyncio
async def test_unregister_api_calls_proxy(self, http_client, mock_proxy):
"""unregister_api should call proxy with correct arguments."""

View File

@@ -64,10 +64,47 @@ def test_init_plugin_template_includes_readme(tmp_path: Path, monkeypatch) -> No
_init_plugin(target.name)
assert (target / "README.md").exists()
assert "astrbot-sdk dev --local --watch" in (target / "README.md").read_text(
encoding="utf-8"
readme = (target / "README.md").read_text(encoding="utf-8")
test_file = (target / "tests" / "test_plugin.py").read_text(encoding="utf-8")
assert "astrbot-sdk dev --local --watch --event-text hello" in readme
assert "PluginHarness.from_plugin_dir" in test_file
assert "test_hello_dispatch" in test_file
def test_mock_context_accepts_plugin_metadata() -> None:
from astrbot_sdk.testing import MockContext
ctx = MockContext(
plugin_id="demo_plugin",
plugin_metadata={
"display_name": "Demo Plugin",
"author": "tester",
"version": "1.2.3",
},
)
plugin = ctx.router._plugins["demo_plugin"].metadata
assert plugin["display_name"] == "Demo Plugin"
assert plugin["author"] == "tester"
assert plugin["version"] == "1.2.3"
def test_plugin_harness_from_plugin_dir_builds_expected_config() -> None:
from astrbot_sdk.testing import PluginHarness
plugin_dir = _repo_root() / "examples" / "hello_plugin"
harness = PluginHarness.from_plugin_dir(
plugin_dir,
session_id="custom-session",
platform="qq",
)
assert harness.config.plugin_dir == plugin_dir
assert harness.config.session_id == "custom-session"
assert harness.config.platform == "qq"
@pytest.mark.asyncio
async def test_plugin_harness_dispatches_sample_plugin() -> None:
@@ -101,11 +138,11 @@ async def test_plugin_harness_supports_metadata_and_http_commands() -> None:
@pytest.mark.asyncio
async def test_example_hello_plugin_dispatches_commands() -> None:
from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness
from astrbot_sdk.testing import PluginHarness
plugin_dir = _repo_root() / "examples" / "hello_plugin"
async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness:
async with PluginHarness.from_plugin_dir(plugin_dir) as harness:
hello_records = await harness.dispatch_text("hello")
about_records = await harness.dispatch_text("about")
@@ -114,6 +151,51 @@ async def test_example_hello_plugin_dispatches_commands() -> None:
assert any("Hello Plugin" in (record.text or "") for record in about_records)
def test_dev_infers_plugin_dir_from_current_directory() -> None:
plugin_dir = _repo_root() / "examples" / "hello_plugin"
process = subprocess.run(
[
sys.executable,
"-m",
"astrbot_sdk",
"dev",
"--local",
"--event-text",
"hello",
],
capture_output=True,
text=True,
check=False,
env=_source_env(),
cwd=plugin_dir,
)
assert process.returncode == 0, process.stderr
assert "[text][local-session] Hello, World!" in process.stdout
def test_dev_requires_plugin_dir_or_plugin_yaml_in_cwd(tmp_path: Path) -> None:
process = subprocess.run(
[
sys.executable,
"-m",
"astrbot_sdk",
"dev",
"--local",
"--event-text",
"hello",
],
capture_output=True,
text=True,
check=False,
env=_source_env(),
cwd=tmp_path,
)
assert process.returncode != 0
assert "当前目录未找到 plugin.yaml" in process.stderr
@pytest.mark.asyncio
async def test_plugin_harness_reports_component_load_errors(tmp_path: Path) -> None:
from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness, _PluginLoadError