From 84184c4c0ac71c8d06564b31e2a1b2c2a104c6f0 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 14 Mar 2026 23:37:09 +0800 Subject: [PATCH] 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 --- AGENTS.md | 76 +++--- CLAUDE.md | 87 ++----- README.md | 36 ++- examples/hello_plugin/README.md | 10 +- examples/hello_plugin/tests/conftest.py | 10 + examples/hello_plugin/tests/test_dispatch.py | 15 ++ examples/hello_plugin/tests/test_plugin.py | 33 ++- src-new/astrbot_sdk/_invocation_context.py | 54 ++++- src-new/astrbot_sdk/cli.py | 87 ++++--- src-new/astrbot_sdk/clients/http.py | 36 ++- src-new/astrbot_sdk/context.py | 63 ++++- src-new/astrbot_sdk/decorators.py | 241 ++++++++++++++++++- src-new/astrbot_sdk/errors.py | 129 +++++++++- src-new/astrbot_sdk/events.py | 118 +++++++++ src-new/astrbot_sdk/runtime/loader.py | 4 +- src-new/astrbot_sdk/star.py | 86 ++++++- src-new/astrbot_sdk/testing.py | 61 ++++- test_plugin/new/commands/hello.py | 91 ++++--- tests_v4/test_decorators.py | 57 +++++ tests_v4/test_events.py | 50 ++++ tests_v4/test_http_metadata_clients.py | 70 ++++++ tests_v4/test_testing_module.py | 90 ++++++- 22 files changed, 1269 insertions(+), 235 deletions(-) create mode 100644 examples/hello_plugin/tests/conftest.py create mode 100644 examples/hello_plugin/tests/test_dispatch.py diff --git a/AGENTS.md b/AGENTS.md index 29c3c9122..09b79652a 100644 --- a/AGENTS.md +++ b/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()` 复用旧代码;热重载前必须先按插件目录清理模块缓存。 diff --git a/CLAUDE.md b/CLAUDE.md index 855ef0e27..de010b89c 100644 --- a/CLAUDE.md +++ b/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. diff --git a/README.md b/README.md index 4b745f97c..547071150 100644 --- a/README.md +++ b/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) +``` + ## 示例插件 面向插件作者的最小示例在: diff --git a/examples/hello_plugin/README.md b/examples/hello_plugin/README.md index b460f56fb..bb95f4cc2 100644 --- a/examples/hello_plugin/README.md +++ b/examples/hello_plugin/README.md @@ -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 diff --git a/examples/hello_plugin/tests/conftest.py b/examples/hello_plugin/tests/conftest.py new file mode 100644 index 000000000..b43c33dcd --- /dev/null +++ b/examples/hello_plugin/tests/conftest.py @@ -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)) diff --git a/examples/hello_plugin/tests/test_dispatch.py b/examples/hello_plugin/tests/test_dispatch.py new file mode 100644 index 000000000..0d18b05d4 --- /dev/null +++ b/examples/hello_plugin/tests/test_dispatch.py @@ -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) diff --git a/examples/hello_plugin/tests/test_plugin.py b/examples/hello_plugin/tests/test_plugin.py index 289c531e3..7b03305bb 100644 --- a/examples/hello_plugin/tests/test_plugin.py +++ b/examples/hello_plugin/tests/test_plugin.py @@ -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) diff --git a/src-new/astrbot_sdk/_invocation_context.py b/src-new/astrbot_sdk/_invocation_context.py index c7fc64621..2fe2ec1d5 100644 --- a/src-new/astrbot_sdk/_invocation_context.py +++ b/src-new/astrbot_sdk/_invocation_context.py @@ -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 diff --git a/src-new/astrbot_sdk/cli.py b/src-new/astrbot_sdk/cli.py index 90191f424..bd3a57643 100644 --- a/src-new/astrbot_sdk/cli.py +++ b/src-new/astrbot_sdk/cli.py @@ -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, diff --git a/src-new/astrbot_sdk/clients/http.py b/src-new/astrbot_sdk/clients/http.py index 71b11d409..ec798ef2a 100644 --- a/src-new/astrbot_sdk/clients/http.py +++ b/src-new/astrbot_sdk/clients/http.py @@ -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, }, ) diff --git a/src-new/astrbot_sdk/context.py b/src-new/astrbot_sdk/context.py index 21fe9c0ef..fede4c642 100644 --- a/src-new/astrbot_sdk/context.py +++ b/src-new/astrbot_sdk/context.py @@ -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) diff --git a/src-new/astrbot_sdk/decorators.py b/src-new/astrbot_sdk/decorators.py index 7c2ecb382..c94020ce7 100644 --- a/src-new/astrbot_sdk/decorators.py +++ b/src-new/astrbot_sdk/decorators.py @@ -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, ) diff --git a/src-new/astrbot_sdk/errors.py b/src-new/astrbot_sdk/errors.py index f188e15ef..7b3e90dc5 100644 --- a/src-new/astrbot_sdk/errors.py +++ b/src-new/astrbot_sdk/errors.py @@ -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", "未知错误")), diff --git a/src-new/astrbot_sdk/events.py b/src-new/astrbot_sdk/events.py index 22ae537fd..297444ce9 100644 --- a/src-new/astrbot_sdk/events.py +++ b/src-new/astrbot_sdk/events.py @@ -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) diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index b966f557e..1a4f89469 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -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): diff --git a/src-new/astrbot_sdk/star.py b/src-new/astrbot_sdk/star.py index 0169f8530..c2a4f527f 100644 --- a/src-new/astrbot_sdk/star.py +++ b/src-new/astrbot_sdk/star.py @@ -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 diff --git a/src-new/astrbot_sdk/testing.py b/src-new/astrbot_sdk/testing.py index a2ed8c589..adbbb8dc4 100644 --- a/src-new/astrbot_sdk/testing.py +++ b/src-new/astrbot_sdk/testing.py @@ -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 diff --git a/test_plugin/new/commands/hello.py b/test_plugin/new/commands/hello.py index c647f2dc7..55c8a41cc 100644 --- a/test_plugin/new/commands/hello.py +++ b/test_plugin/new/commands/hello.py @@ -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, diff --git a/tests_v4/test_decorators.py b/tests_v4/test_decorators.py index 5de3ef916..434dcc2fc 100644 --- a/tests_v4/test_decorators.py +++ b/tests_v4/test_decorators.py @@ -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.""" diff --git a/tests_v4/test_events.py b/tests_v4/test_events.py index f169497b6..2561a8e6d 100644 --- a/tests_v4/test_events.py +++ b/tests_v4/test_events.py @@ -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( diff --git a/tests_v4/test_http_metadata_clients.py b/tests_v4/test_http_metadata_clients.py index 146504ef7..2dc38c893 100644 --- a/tests_v4/test_http_metadata_clients.py +++ b/tests_v4/test_http_metadata_clients.py @@ -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.""" diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py index 36e09a741..cf336c08b 100644 --- a/tests_v4/test_testing_module.py +++ b/tests_v4/test_testing_module.py @@ -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