mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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:
76
AGENTS.md
76
AGENTS.md
@@ -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()` 复用旧代码;热重载前必须先按插件目录清理模块缓存。
|
||||
|
||||
87
CLAUDE.md
87
CLAUDE.md
@@ -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.
|
||||
|
||||
36
README.md
36
README.md
@@ -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)
|
||||
```
|
||||
|
||||
## 示例插件
|
||||
|
||||
面向插件作者的最小示例在:
|
||||
|
||||
@@ -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
|
||||
|
||||
10
examples/hello_plugin/tests/conftest.py
Normal file
10
examples/hello_plugin/tests/conftest.py
Normal 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))
|
||||
15
examples/hello_plugin/tests/test_dispatch.py
Normal file
15
examples/hello_plugin/tests/test_dispatch.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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", "未知错误")),
|
||||
|
||||
@@ -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: 会话 ID,None 时自动从 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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user