From 94514fb19b949ccacdae52c7a874cda3100fbe1a Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 14 Mar 2026 22:07:10 +0800 Subject: [PATCH] feat: Enhance handler and capability dispatchers with improved error handling - Updated HandlerDispatcher to raise TypeError for uninjectable required parameters, logging errors appropriately. - Refactored CapabilityDispatcher to raise TypeError for missing required parameters during capability execution. - Renamed _load_plugin_config to load_plugin_config for clarity and consistency. - Introduced _sync_plugin_registry method in SupervisorRuntime to manage plugin capabilities more effectively. - Enhanced capability registration logic to handle naming conflicts with better logging and automatic renaming. - Added tests for handler and capability dispatchers to ensure proper error handling and functionality. - Implemented new HTTP and metadata capabilities with corresponding tests for registration and retrieval. - Improved MemoryClient methods with additional tests for save_with_ttl, get_many, delete_many, and stats. - Added tests for the testing module to ensure proper import and functionality of PluginHarness. --- AGENTS.md | 1 + CLAUDE.md | 3 + src-new/astrbot_sdk/cli.py | 98 ++-- src-new/astrbot_sdk/clients/http.py | 33 +- src-new/astrbot_sdk/clients/memory.py | 111 ++++- src-new/astrbot_sdk/clients/metadata.py | 12 +- src-new/astrbot_sdk/context.py | 4 +- src-new/astrbot_sdk/protocol/descriptors.py | 143 ++++++ .../astrbot_sdk/runtime/capability_router.py | 467 +++++++++++++++++- .../astrbot_sdk/runtime/handler_dispatcher.py | 18 +- src-new/astrbot_sdk/runtime/loader.py | 2 +- src-new/astrbot_sdk/runtime/supervisor.py | 92 +++- src-new/astrbot_sdk/testing.py | 62 ++- test_plugin/new/commands/hello.py | 27 +- tests_v4/test_capability_router.py | 139 ++++++ tests_v4/test_handler_dispatcher.py | 88 ++++ tests_v4/test_http_metadata_clients.py | 30 +- tests_v4/test_memory_client.py | 146 ++++++ tests_v4/test_testing_module.py | 70 +++ 19 files changed, 1411 insertions(+), 135 deletions(-) create mode 100644 tests_v4/test_handler_dispatcher.py create mode 100644 tests_v4/test_testing_module.py diff --git a/AGENTS.md b/AGENTS.md index 91b4cf3a9..8289db732 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,3 +71,4 @@ old文件夹是兼容旧插件的测试,旧插件全部放进old文件夹 - 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 很容易漏掉这类回归。 diff --git a/CLAUDE.md b/CLAUDE.md index ff7edb1f7..87eb1d79d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,9 @@ - 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`)解决。 # 开发命令 diff --git a/src-new/astrbot_sdk/cli.py b/src-new/astrbot_sdk/cli.py index 74ac2524a..0a4ebc03b 100644 --- a/src-new/astrbot_sdk/cli.py +++ b/src-new/astrbot_sdk/cli.py @@ -18,13 +18,6 @@ from loguru import logger from .errors import AstrBotError from .runtime.bootstrap import run_plugin_worker, run_supervisor, run_websocket_server from .runtime.loader import load_plugin, load_plugin_spec, validate_plugin_spec -from .testing import ( - LocalRuntimeConfig, - PluginHarness, - StdoutPlatformSink, - _PluginExecutionError, - _PluginLoadError, -) EXIT_OK = 0 EXIT_UNEXPECTED = 1 @@ -51,6 +44,14 @@ class _CliPluginValidationError(RuntimeError): """CLI 侧的插件结构或打包校验失败。""" +class _CliPluginLoadError(RuntimeError): + """CLI 侧的本地开发插件加载失败。""" + + +class _CliPluginExecutionError(RuntimeError): + """CLI 侧的本地开发插件执行失败。""" + + def setup_logger(verbose: bool = False) -> None: """初始化 CLI 使用的日志配置。""" logger.remove() @@ -121,7 +122,7 @@ def _classify_cli_exception(exc: Exception) -> tuple[int, str, str]: exc, ( _CliPluginValidationError, - _PluginLoadError, + _CliPluginLoadError, FileNotFoundError, ImportError, ModuleNotFoundError, @@ -138,7 +139,7 @@ def _classify_cli_exception(exc: Exception) -> tuple[int, str, str]: "dispatch_error", "请检查 handler 或 capability 是否已正确注册", ) - if isinstance(exc, _PluginExecutionError): + if isinstance(exc, _CliPluginExecutionError): return ( EXIT_PLUGIN_EXECUTION, "plugin_execution_error", @@ -178,6 +179,14 @@ async def _run_local_dev( group_id: str | None, event_type: str, ) -> None: + from .testing import ( + LocalRuntimeConfig, + PluginHarness, + StdoutPlatformSink, + _PluginExecutionError, + _PluginLoadError, + ) + sink = StdoutPlatformSink(stream=sys.stdout) harness = PluginHarness( LocalRuntimeConfig( @@ -197,40 +206,45 @@ async def _run_local_dev( "group_id": group_id, "event_type": event_type, } - async with harness: - if interactive: - click.echo( - "本地交互模式已启动。可用命令:/session /user /platform /group /private /event /exit" - ) - while True: - line = await asyncio.to_thread(sys.stdin.readline) - if not line: - break - text = line.strip() - if not text: - continue - if _handle_dev_meta_command(text, state): - if text in {"/exit", "/quit"}: - break - continue - await harness.dispatch_text( - text, - session_id=str(state["session_id"]), - user_id=str(state["user_id"]), - platform=str(state["platform"]), - group_id=typing.cast(str | None, state["group_id"]), - event_type=str(state["event_type"]), + try: + async with harness: + if interactive: + click.echo( + "本地交互模式已启动。可用命令:/session /user /platform /group /private /event /exit" ) - return - assert event_text is not None - await harness.dispatch_text( - event_text, - session_id=session_id, - user_id=user_id, - platform=platform, - group_id=group_id, - event_type=event_type, - ) + while True: + line = await asyncio.to_thread(sys.stdin.readline) + if not line: + break + text = line.strip() + if not text: + continue + if _handle_dev_meta_command(text, state): + if text in {"/exit", "/quit"}: + break + continue + await harness.dispatch_text( + text, + session_id=str(state["session_id"]), + user_id=str(state["user_id"]), + platform=str(state["platform"]), + group_id=typing.cast(str | None, state["group_id"]), + event_type=str(state["event_type"]), + ) + return + assert event_text is not None + await harness.dispatch_text( + event_text, + session_id=session_id, + user_id=user_id, + platform=platform, + group_id=group_id, + event_type=event_type, + ) + except _PluginLoadError as exc: + raise _CliPluginLoadError(str(exc)) from exc + except _PluginExecutionError as exc: + raise _CliPluginExecutionError(str(exc)) from exc def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool: diff --git a/src-new/astrbot_sdk/clients/http.py b/src-new/astrbot_sdk/clients/http.py index b9aa00dee..87249251a 100644 --- a/src-new/astrbot_sdk/clients/http.py +++ b/src-new/astrbot_sdk/clients/http.py @@ -50,13 +50,23 @@ class HTTPClient: _proxy: CapabilityProxy 实例,用于远程能力调用 """ - def __init__(self, proxy: CapabilityProxy) -> None: + def __init__( + self, + proxy: CapabilityProxy, + plugin_id: str | None = None, + ) -> None: """初始化 HTTP 客户端。 Args: proxy: CapabilityProxy 实例 """ self._proxy = proxy + self._plugin_id = plugin_id + + def _payload_with_plugin(self, payload: dict[str, Any]) -> dict[str, Any]: + if self._plugin_id is None: + return payload + return {"plugin_id": self._plugin_id, **payload} async def register_api( self, @@ -86,12 +96,14 @@ class HTTPClient: await self._proxy.call( "http.register_api", - { - "route": route, - "methods": methods, - "handler_capability": handler_capability, - "description": description, - }, + self._payload_with_plugin( + { + "route": route, + "methods": methods, + "handler_capability": handler_capability, + "description": description, + } + ), ) async def unregister_api( @@ -111,7 +123,7 @@ class HTTPClient: await self._proxy.call( "http.unregister_api", - {"route": route, "methods": methods}, + self._payload_with_plugin({"route": route, "methods": methods}), ) async def list_apis(self) -> list[dict[str, Any]]: @@ -125,5 +137,8 @@ class HTTPClient: for api in apis: print(f"{api['route']}: {api['methods']}") """ - output = await self._proxy.call("http.list_apis", {}) + output = await self._proxy.call( + "http.list_apis", + self._payload_with_plugin({}), + ) return output.get("apis", []) diff --git a/src-new/astrbot_sdk/clients/memory.py b/src-new/astrbot_sdk/clients/memory.py index 00d9dfcbf..3edcaad7f 100644 --- a/src-new/astrbot_sdk/clients/memory.py +++ b/src-new/astrbot_sdk/clients/memory.py @@ -8,8 +8,12 @@ 新版: 新增 MemoryClient,提供语义搜索能力 - search(): 语义搜索记忆项 - save(): 保存记忆项 + - save_with_ttl(): 保存带过期时间的记忆项 - get(): 精确获取单个记忆项 + - get_many(): 批量获取多个记忆项 - delete(): 删除记忆项 + - delete_many(): 批量删除多个记忆项 + - stats(): 获取记忆统计信息 设计说明: MemoryClient 与 DBClient 的区别: @@ -20,11 +24,6 @@ - 存储用户偏好和设置 - 记录对话摘要 - 缓存 AI 推理结果 - -TODO: - - 缺少记忆项过期时间 (TTL) 支持 - - 缺少批量操作支持 - - 缺少记忆统计和容量查询 """ from __future__ import annotations @@ -137,3 +136,105 @@ class MemoryClient: await ctx.memory.delete("old_note") """ await self._proxy.call("memory.delete", {"key": key}) + + async def save_with_ttl( + self, + key: str, + value: dict[str, Any], + ttl_seconds: int, + ) -> None: + """保存带过期时间的记忆项。 + + 与 save() 不同,此方法允许设置记忆项的存活时间(TTL), + 过期后记忆项将自动删除。 + + Args: + key: 记忆项的唯一标识键 + value: 要存储的数据字典 + ttl_seconds: 存活时间(秒),必须大于 0 + + Raises: + TypeError: 如果 value 不是 dict 类型 + ValueError: 如果 ttl_seconds 小于 1 + + 示例: + # 保存临时会话状态,1小时后过期 + await ctx.memory.save_with_ttl( + "session_temp", + {"state": "waiting"}, + ttl_seconds=3600, + ) + """ + if not isinstance(value, dict): + raise TypeError("memory.save_with_ttl 的 value 必须是 dict") + if ttl_seconds < 1: + raise ValueError("ttl_seconds 必须大于 0") + await self._proxy.call( + "memory.save_with_ttl", + {"key": key, "value": value, "ttl_seconds": ttl_seconds}, + ) + + async def get_many( + self, + keys: list[str], + ) -> list[dict[str, Any]]: + """批量获取多个记忆项。 + + 一次性获取多个键对应的记忆内容,比多次调用 get() 更高效。 + + Args: + keys: 记忆项键名列表 + + Returns: + 记忆项列表,每项包含 key 和 value 字段, + 不存在的键返回 value 为 None + + 示例: + items = await ctx.memory.get_many(["pref1", "pref2", "pref3"]) + for item in items: + if item["value"]: + print(f"{item['key']}: {item['value']}") + """ + output = await self._proxy.call("memory.get_many", {"keys": keys}) + items = output.get("items") + if not isinstance(items, (list, tuple)): + return [] + return [dict(item) for item in items] + + async def delete_many(self, keys: list[str]) -> int: + """批量删除多个记忆项。 + + 一次性删除多个键对应的记忆项,返回实际删除的数量。 + + Args: + keys: 要删除的记忆项键名列表 + + Returns: + 实际删除的记忆项数量 + + 示例: + deleted = await ctx.memory.delete_many(["old1", "old2", "old3"]) + print(f"删除了 {deleted} 条记忆") + """ + output = await self._proxy.call("memory.delete_many", {"keys": keys}) + return int(output.get("deleted_count", 0)) + + async def stats(self) -> dict[str, Any]: + """获取记忆系统统计信息。 + + 返回记忆系统的当前状态,包括总条目数等统计信息。 + + Returns: + 统计信息字典,包含: + - total_items: 总记忆条目数 + - total_bytes: 总占用字节数(可选) + + 示例: + stats = await ctx.memory.stats() + print(f"记忆库共有 {stats['total_items']} 条记录") + """ + output = await self._proxy.call("memory.stats", {}) + return { + "total_items": output.get("total_items", 0), + "total_bytes": output.get("total_bytes"), + } diff --git a/src-new/astrbot_sdk/clients/metadata.py b/src-new/astrbot_sdk/clients/metadata.py index 333d2fcbc..586dc8805 100644 --- a/src-new/astrbot_sdk/clients/metadata.py +++ b/src-new/astrbot_sdk/clients/metadata.py @@ -60,6 +60,9 @@ class MetadataClient: self._proxy = proxy self._plugin_id = plugin_id + def _payload(self, payload: dict[str, Any]) -> dict[str, Any]: + return {"plugin_id": self._plugin_id, **payload} + async def get_plugin(self, name: str) -> PluginMetadata | None: """获取指定插件的元数据。 @@ -74,7 +77,10 @@ class MetadataClient: if meta: print(f"{meta.display_name} v{meta.version}") """ - output = await self._proxy.call("metadata.get_plugin", {"name": name}) + output = await self._proxy.call( + "metadata.get_plugin", + self._payload({"name": name}), + ) data = output.get("plugin") if data is None: return None @@ -91,7 +97,7 @@ class MetadataClient: for p in plugins: print(f"- {p.display_name} ({p.name})") """ - output = await self._proxy.call("metadata.list_plugins", {}) + output = await self._proxy.call("metadata.list_plugins", self._payload({})) items = output.get("plugins", []) return [ PluginMetadata.from_dict(item) for item in items if isinstance(item, dict) @@ -138,6 +144,6 @@ class MetadataClient: return None output = await self._proxy.call( "metadata.get_plugin_config", - {"name": target}, + self._payload({"name": target}), ) return output.get("config") diff --git a/src-new/astrbot_sdk/context.py b/src-new/astrbot_sdk/context.py index 5e45b6e83..76e2b48ac 100644 --- a/src-new/astrbot_sdk/context.py +++ b/src-new/astrbot_sdk/context.py @@ -1,8 +1,6 @@ """v4 原生运行时上下文。 `Context` 负责组合 v4 原生 capability 客户端。 -旧版 `conversation_manager`、`send_message()` 等兼容入口不在这里实现, -而由 `_legacy_api.py` 承接。 """ from __future__ import annotations @@ -61,7 +59,7 @@ class Context: self.memory = MemoryClient(proxy) self.db = DBClient(proxy) self.platform = PlatformClient(proxy) - self.http = HTTPClient(proxy) + self.http = HTTPClient(proxy, plugin_id=plugin_id) self.metadata = MetadataClient(proxy, plugin_id) self.plugin_id = plugin_id self.logger = logger or base_logger.bind(plugin_id=plugin_id) diff --git a/src-new/astrbot_sdk/protocol/descriptors.py b/src-new/astrbot_sdk/protocol/descriptors.py index bdeca089c..80946a1b5 100644 --- a/src-new/astrbot_sdk/protocol/descriptors.py +++ b/src-new/astrbot_sdk/protocol/descriptors.py @@ -101,6 +101,41 @@ MEMORY_DELETE_INPUT_SCHEMA = _object_schema( key={"type": "string"}, ) MEMORY_DELETE_OUTPUT_SCHEMA = _object_schema() +MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA = _object_schema( + required=("key", "value", "ttl_seconds"), + key={"type": "string"}, + value={"type": "object"}, + ttl_seconds={"type": "integer", "minimum": 1}, +) +MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA = _object_schema() +MEMORY_GET_MANY_INPUT_SCHEMA = _object_schema( + required=("keys",), + keys={"type": "array", "items": {"type": "string"}}, +) +MEMORY_GET_MANY_OUTPUT_SCHEMA = _object_schema( + required=("items",), + items={ + "type": "array", + "items": _object_schema( + required=("key", "value"), + key={"type": "string"}, + value=_nullable({"type": "object"}), + ), + }, +) +MEMORY_DELETE_MANY_INPUT_SCHEMA = _object_schema( + required=("keys",), + keys={"type": "array", "items": {"type": "string"}}, +) +MEMORY_DELETE_MANY_OUTPUT_SCHEMA = _object_schema( + required=("deleted_count",), + deleted_count={"type": "integer"}, +) +MEMORY_STATS_INPUT_SCHEMA = _object_schema() +MEMORY_STATS_OUTPUT_SCHEMA = _object_schema( + total_items={"type": "integer"}, + total_bytes=_nullable({"type": "integer"}), +) DB_GET_INPUT_SCHEMA = _object_schema( required=("key",), key={"type": "string"}, @@ -203,6 +238,54 @@ PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA = _object_schema( required=("members",), members={"type": "array", "items": {"type": "object"}}, ) +HTTP_REGISTER_API_INPUT_SCHEMA = _object_schema( + required=("route", "methods", "handler_capability"), + route={"type": "string"}, + methods={"type": "array", "items": {"type": "string"}}, + handler_capability={"type": "string"}, + description={"type": "string"}, + plugin_id=_nullable({"type": "string"}), +) +HTTP_REGISTER_API_OUTPUT_SCHEMA = _object_schema() +HTTP_UNREGISTER_API_INPUT_SCHEMA = _object_schema( + required=("route", "methods"), + route={"type": "string"}, + methods={"type": "array", "items": {"type": "string"}}, + plugin_id=_nullable({"type": "string"}), +) +HTTP_UNREGISTER_API_OUTPUT_SCHEMA = _object_schema() +HTTP_LIST_APIS_INPUT_SCHEMA = _object_schema( + plugin_id=_nullable({"type": "string"}), +) +HTTP_LIST_APIS_OUTPUT_SCHEMA = _object_schema( + required=("apis",), + apis={"type": "array", "items": {"type": "object"}}, +) +METADATA_GET_PLUGIN_INPUT_SCHEMA = _object_schema( + required=("name",), + name={"type": "string"}, + plugin_id=_nullable({"type": "string"}), +) +METADATA_GET_PLUGIN_OUTPUT_SCHEMA = _object_schema( + required=("plugin",), + plugin=_nullable({"type": "object"}), +) +METADATA_LIST_PLUGINS_INPUT_SCHEMA = _object_schema( + plugin_id=_nullable({"type": "string"}), +) +METADATA_LIST_PLUGINS_OUTPUT_SCHEMA = _object_schema( + required=("plugins",), + plugins={"type": "array", "items": {"type": "object"}}, +) +METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA = _object_schema( + required=("name",), + name={"type": "string"}, + plugin_id=_nullable({"type": "string"}), +) +METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA = _object_schema( + required=("config",), + config=_nullable({"type": "object"}), +) BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { "llm.chat": { @@ -233,6 +316,22 @@ BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { "input": MEMORY_DELETE_INPUT_SCHEMA, "output": MEMORY_DELETE_OUTPUT_SCHEMA, }, + "memory.save_with_ttl": { + "input": MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA, + "output": MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA, + }, + "memory.get_many": { + "input": MEMORY_GET_MANY_INPUT_SCHEMA, + "output": MEMORY_GET_MANY_OUTPUT_SCHEMA, + }, + "memory.delete_many": { + "input": MEMORY_DELETE_MANY_INPUT_SCHEMA, + "output": MEMORY_DELETE_MANY_OUTPUT_SCHEMA, + }, + "memory.stats": { + "input": MEMORY_STATS_INPUT_SCHEMA, + "output": MEMORY_STATS_OUTPUT_SCHEMA, + }, "db.get": { "input": DB_GET_INPUT_SCHEMA, "output": DB_GET_OUTPUT_SCHEMA, @@ -277,6 +376,30 @@ BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { "input": PLATFORM_GET_MEMBERS_INPUT_SCHEMA, "output": PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA, }, + "http.register_api": { + "input": HTTP_REGISTER_API_INPUT_SCHEMA, + "output": HTTP_REGISTER_API_OUTPUT_SCHEMA, + }, + "http.unregister_api": { + "input": HTTP_UNREGISTER_API_INPUT_SCHEMA, + "output": HTTP_UNREGISTER_API_OUTPUT_SCHEMA, + }, + "http.list_apis": { + "input": HTTP_LIST_APIS_INPUT_SCHEMA, + "output": HTTP_LIST_APIS_OUTPUT_SCHEMA, + }, + "metadata.get_plugin": { + "input": METADATA_GET_PLUGIN_INPUT_SCHEMA, + "output": METADATA_GET_PLUGIN_OUTPUT_SCHEMA, + }, + "metadata.list_plugins": { + "input": METADATA_LIST_PLUGINS_INPUT_SCHEMA, + "output": METADATA_LIST_PLUGINS_OUTPUT_SCHEMA, + }, + "metadata.get_plugin_config": { + "input": METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA, + "output": METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA, + }, } @@ -546,6 +669,12 @@ __all__ = [ "DB_WATCH_OUTPUT_SCHEMA", "EventTrigger", "HandlerDescriptor", + "HTTP_LIST_APIS_INPUT_SCHEMA", + "HTTP_LIST_APIS_OUTPUT_SCHEMA", + "HTTP_REGISTER_API_INPUT_SCHEMA", + "HTTP_REGISTER_API_OUTPUT_SCHEMA", + "HTTP_UNREGISTER_API_INPUT_SCHEMA", + "HTTP_UNREGISTER_API_OUTPUT_SCHEMA", "JSONSchema", "LLM_CHAT_INPUT_SCHEMA", "LLM_CHAT_OUTPUT_SCHEMA", @@ -555,12 +684,26 @@ __all__ = [ "LLM_STREAM_CHAT_OUTPUT_SCHEMA", "MEMORY_DELETE_INPUT_SCHEMA", "MEMORY_DELETE_OUTPUT_SCHEMA", + "MEMORY_DELETE_MANY_INPUT_SCHEMA", + "MEMORY_DELETE_MANY_OUTPUT_SCHEMA", "MEMORY_GET_INPUT_SCHEMA", "MEMORY_GET_OUTPUT_SCHEMA", + "MEMORY_GET_MANY_INPUT_SCHEMA", + "MEMORY_GET_MANY_OUTPUT_SCHEMA", "MEMORY_SAVE_INPUT_SCHEMA", "MEMORY_SAVE_OUTPUT_SCHEMA", + "MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA", + "MEMORY_SAVE_WITH_TTL_OUTPUT_SCHEMA", "MEMORY_SEARCH_INPUT_SCHEMA", "MEMORY_SEARCH_OUTPUT_SCHEMA", + "MEMORY_STATS_INPUT_SCHEMA", + "MEMORY_STATS_OUTPUT_SCHEMA", + "METADATA_GET_PLUGIN_CONFIG_INPUT_SCHEMA", + "METADATA_GET_PLUGIN_CONFIG_OUTPUT_SCHEMA", + "METADATA_GET_PLUGIN_INPUT_SCHEMA", + "METADATA_GET_PLUGIN_OUTPUT_SCHEMA", + "METADATA_LIST_PLUGINS_INPUT_SCHEMA", + "METADATA_LIST_PLUGINS_OUTPUT_SCHEMA", "MessageTrigger", "PLATFORM_GET_MEMBERS_INPUT_SCHEMA", "PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA", diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py index 148d6c028..9f37d6b08 100644 --- a/src-new/astrbot_sdk/runtime/capability_router.py +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -15,8 +15,12 @@ llm.stream_chat: 流式 LLM 聊天 memory.search: 搜索记忆 memory.save: 保存记忆 + memory.save_with_ttl: 保存带过期时间的记忆 memory.get: 读取单条记忆 + memory.get_many: 批量获取多条记忆 memory.delete: 删除记忆 + memory.delete_many: 批量删除多条记忆 + memory.stats: 获取记忆统计信息 db.get: 读取 KV 存储 db.set: 写入 KV 存储 db.delete: 删除 KV 存储 @@ -28,6 +32,12 @@ platform.send_image: 发送图片 platform.send_chain: 发送消息链 platform.get_members: 获取群成员 + http.register_api: 注册 HTTP 路由到插件 capability + http.unregister_api: 注销 HTTP 路由 + http.list_apis: 查询已注册的 HTTP 路由 + metadata.get_plugin: 获取单个插件元数据 + metadata.list_plugins: 列出所有插件元数据 + metadata.get_plugin_config: 获取插件配置 与旧版对比: 旧版: @@ -132,17 +142,58 @@ class _CapabilityRegistration: exposed: bool = True +@dataclass(slots=True) +class _RegisteredPlugin: + metadata: dict[str, Any] + config: dict[str, Any] + + class CapabilityRouter: def __init__(self) -> None: self._registrations: dict[str, _CapabilityRegistration] = {} self.db_store: dict[str, Any] = {} self.memory_store: dict[str, dict[str, Any]] = {} self.sent_messages: list[dict[str, Any]] = [] + self.http_api_store: list[dict[str, Any]] = [] + self._plugins: dict[str, _RegisteredPlugin] = {} self._db_watch_subscriptions: dict[ str, tuple[str | None, asyncio.Queue[dict[str, Any]]] ] = {} self._register_builtin_capabilities() + def upsert_plugin( + self, + *, + metadata: dict[str, Any], + config: dict[str, Any] | None = None, + ) -> None: + name = str(metadata.get("name", "")).strip() + if not name: + raise ValueError("plugin metadata must include a non-empty name") + normalized_metadata = dict(metadata) + normalized_metadata.setdefault("display_name", name) + normalized_metadata.setdefault("description", "") + normalized_metadata.setdefault("author", "") + normalized_metadata.setdefault("version", "0.0.0") + normalized_metadata.setdefault("enabled", True) + self._plugins[name] = _RegisteredPlugin( + metadata=normalized_metadata, + config=dict(config or {}), + ) + + def set_plugin_enabled(self, name: str, enabled: bool) -> None: + plugin = self._plugins.get(name) + if plugin is None: + return + plugin.metadata["enabled"] = enabled + + def remove_http_apis_for_plugin(self, plugin_id: str) -> None: + self.http_api_store = [ + entry + for entry in self.http_api_store + if entry.get("plugin_id") != plugin_id + ] + def _emit_db_change(self, *, op: str, key: str, value: Any | None) -> None: event = {"op": op, "key": key, "value": value} for prefix, queue in list(self._db_watch_subscriptions.values()): @@ -249,11 +300,13 @@ class CapabilityRouter: # ------------------------------------------------------------------ def _register_builtin_capabilities(self) -> None: - """注册全部 18 条内建 capability。""" + """注册全部内建 capability。""" self._register_llm_capabilities() self._register_memory_capabilities() self._register_db_capabilities() self._register_platform_capabilities() + self._register_http_capabilities() + self._register_metadata_capabilities() def _builtin_descriptor( self, @@ -379,6 +432,70 @@ class CapabilityRouter: self.memory_store.pop(str(payload.get("key", "")), None) return {} + async def _memory_save_with_ttl( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + """保存带 TTL 的记忆项(测试实现,TTL 仅记录但不实际过期)。""" + key = str(payload.get("key", "")) + value = payload.get("value") + ttl_seconds = payload.get("ttl_seconds", 0) + if not isinstance(value, dict): + raise AstrBotError.invalid_input( + "memory.save_with_ttl 的 value 必须是 object" + ) + # 在测试实现中,我们只存储值,TTL 由实际后端实现 + self.memory_store[key] = {"value": value, "ttl_seconds": ttl_seconds} + return {} + + async def _memory_get_many( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + """批量获取多个记忆项。""" + keys_payload = payload.get("keys") + if not isinstance(keys_payload, (list, tuple)): + raise AstrBotError.invalid_input("memory.get_many 的 keys 必须是数组") + keys = [str(item) for item in keys_payload] + items = [] + for key in keys: + stored = self.memory_store.get(key) + # 如果存储的是带 TTL 的结构,提取实际值 + if ( + isinstance(stored, dict) + and "value" in stored + and "ttl_seconds" in stored + ): + value = stored["value"] + else: + value = stored + items.append({"key": key, "value": value}) + return {"items": items} + + async def _memory_delete_many( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + """批量删除多个记忆项。""" + keys_payload = payload.get("keys") + if not isinstance(keys_payload, (list, tuple)): + raise AstrBotError.invalid_input("memory.delete_many 的 keys 必须是数组") + keys = [str(item) for item in keys_payload] + deleted_count = 0 + for key in keys: + if key in self.memory_store: + del self.memory_store[key] + deleted_count += 1 + return {"deleted_count": deleted_count} + + async def _memory_stats( + self, _request_id: str, _payload: dict[str, Any], _token + ) -> dict[str, Any]: + """获取记忆统计信息。""" + total_items = len(self.memory_store) + # 简单估算字节大小 + total_bytes = sum( + len(str(key)) + len(str(value)) for key, value in self.memory_store.items() + ) + return {"total_items": total_items, "total_bytes": total_bytes} + def _register_memory_capabilities(self) -> None: self.register( self._builtin_descriptor("memory.search", "搜索记忆"), @@ -396,6 +513,22 @@ class CapabilityRouter: self._builtin_descriptor("memory.delete", "删除记忆"), call_handler=self._memory_delete, ) + self.register( + self._builtin_descriptor("memory.save_with_ttl", "保存带过期时间的记忆"), + call_handler=self._memory_save_with_ttl, + ) + self.register( + self._builtin_descriptor("memory.get_many", "批量获取记忆"), + call_handler=self._memory_get_many, + ) + self.register( + self._builtin_descriptor("memory.delete_many", "批量删除记忆"), + call_handler=self._memory_delete_many, + ) + self.register( + self._builtin_descriptor("memory.stats", "获取记忆统计信息"), + call_handler=self._memory_stats, + ) # ------------------------------------------------------------------ # DB handlers @@ -605,6 +738,163 @@ class CapabilityRouter: call_handler=self._platform_get_members, ) + # ------------------------------------------------------------------ + # HTTP handlers + # ------------------------------------------------------------------ + + async def _http_register_api( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + methods_payload = payload.get("methods") + if not isinstance(methods_payload, list) or not all( + isinstance(item, str) for item in methods_payload + ): + raise AstrBotError.invalid_input( + "http.register_api 的 methods 必须是 string 数组" + ) + + route = str(payload.get("route", "")).strip() + handler_capability = str(payload.get("handler_capability", "")).strip() + if not route or not handler_capability: + raise AstrBotError.invalid_input( + "http.register_api 需要 route 和 handler_capability" + ) + + plugin_id = payload.get("plugin_id") + plugin_name = str(plugin_id).strip() if isinstance(plugin_id, str) else "" + methods = sorted({method.upper() for method in methods_payload if method}) + entry = { + "route": route, + "methods": methods, + "handler_capability": handler_capability, + "description": str(payload.get("description", "")), + "plugin_id": plugin_name or None, + } + self.http_api_store = [ + item + for item in self.http_api_store + if not ( + item.get("route") == route + and item.get("plugin_id") == entry["plugin_id"] + and item.get("methods") == methods + ) + ] + self.http_api_store.append(entry) + return {} + + async def _http_unregister_api( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + route = str(payload.get("route", "")).strip() + methods_payload = payload.get("methods") + if not isinstance(methods_payload, list) or not all( + isinstance(item, str) for item in methods_payload + ): + raise AstrBotError.invalid_input( + "http.unregister_api 的 methods 必须是 string 数组" + ) + + plugin_id = payload.get("plugin_id") + plugin_name = str(plugin_id).strip() if isinstance(plugin_id, str) else None + methods = {method.upper() for method in methods_payload if method} + updated: list[dict[str, Any]] = [] + for entry in self.http_api_store: + if entry.get("route") != route: + updated.append(entry) + continue + if plugin_name is not None and entry.get("plugin_id") != plugin_name: + updated.append(entry) + continue + if not methods: + continue + remaining_methods = [ + method for method in entry.get("methods", []) if method not in methods + ] + if remaining_methods: + updated.append({**entry, "methods": remaining_methods}) + self.http_api_store = updated + return {} + + async def _http_list_apis( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + plugin_id = payload.get("plugin_id") + plugin_name = str(plugin_id).strip() if isinstance(plugin_id, str) else None + apis = [ + dict(entry) + for entry in self.http_api_store + if plugin_name is None or entry.get("plugin_id") == plugin_name + ] + return {"apis": apis} + + def _register_http_capabilities(self) -> None: + self.register( + self._builtin_descriptor("http.register_api", "注册 HTTP 路由"), + call_handler=self._http_register_api, + ) + self.register( + self._builtin_descriptor("http.unregister_api", "注销 HTTP 路由"), + call_handler=self._http_unregister_api, + ) + self.register( + self._builtin_descriptor("http.list_apis", "列出 HTTP 路由"), + call_handler=self._http_list_apis, + ) + + # ------------------------------------------------------------------ + # Metadata handlers + # ------------------------------------------------------------------ + + async def _metadata_get_plugin( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + name = str(payload.get("name", "")).strip() + plugin = self._plugins.get(name) + if plugin is None: + return {"plugin": None} + return {"plugin": dict(plugin.metadata)} + + async def _metadata_list_plugins( + self, _request_id: str, _payload: dict[str, Any], _token + ) -> dict[str, Any]: + plugins = [ + dict(self._plugins[name].metadata) for name in sorted(self._plugins.keys()) + ] + return {"plugins": plugins} + + async def _metadata_get_plugin_config( + self, _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + name = str(payload.get("name", "")).strip() + caller_plugin_id = payload.get("plugin_id") + if ( + isinstance(caller_plugin_id, str) + and caller_plugin_id + and name != caller_plugin_id + ): + return {"config": None} + plugin = self._plugins.get(name) + if plugin is None: + return {"config": None} + return {"config": dict(plugin.config)} + + def _register_metadata_capabilities(self) -> None: + self.register( + self._builtin_descriptor("metadata.get_plugin", "获取单个插件元数据"), + call_handler=self._metadata_get_plugin, + ) + self.register( + self._builtin_descriptor("metadata.list_plugins", "列出插件元数据"), + call_handler=self._metadata_list_plugins, + ) + self.register( + self._builtin_descriptor( + "metadata.get_plugin_config", + "获取插件配置", + ), + call_handler=self._metadata_get_plugin_config, + ) + # ------------------------------------------------------------------ # Schema validation # ------------------------------------------------------------------ @@ -612,30 +902,159 @@ class CapabilityRouter: def _validate_schema( self, schema: dict[str, Any] | None, - payload: dict[str, Any], + payload: Any, ) -> None: - def schema_allows_null(field_schema: Any) -> bool: - if not isinstance(field_schema, dict): - return False - if field_schema.get("type") == "null": - return True - any_of = field_schema.get("anyOf") - if not isinstance(any_of, list): - return False - return any( - isinstance(candidate, dict) and candidate.get("type") == "null" - for candidate in any_of + if not isinstance(schema, dict) or not schema: + return + self._validate_value(schema, payload, path="") + + def _validate_value( + self, + schema: dict[str, Any], + value: Any, + *, + path: str, + ) -> None: + any_of = schema.get("anyOf") + if isinstance(any_of, list): + for candidate in any_of: + if not isinstance(candidate, dict): + continue + try: + self._validate_value(candidate, value, path=path) + return + except AstrBotError: + continue + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 不符合允许的 schema 约束" ) - if schema is None: + enum = schema.get("enum") + if isinstance(enum, list) and value not in enum: + raise AstrBotError.invalid_input(f"{self._field_label(path)} 必须是 {enum}") + + schema_type = schema.get("type") + if schema_type == "object": + if not isinstance(value, dict): + if not path: + raise AstrBotError.invalid_input("输入必须是 object") + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 object" + ) + properties = schema.get("properties", {}) + required_fields = schema.get("required", []) + for field_name in required_fields: + field_path = self._join_path(path, str(field_name)) + if field_name not in value: + raise AstrBotError.invalid_input(f"缺少必填字段:{field_path}") + field_schema = self._property_schema(properties, field_name) + if value[field_name] is None and not self._schema_allows_null( + field_schema + ): + raise AstrBotError.invalid_input(f"缺少必填字段:{field_path}") + self._validate_value( + field_schema, + value[field_name], + path=field_path, + ) + for field_name, field_value in value.items(): + field_schema = properties.get(field_name) + if isinstance(field_schema, dict): + self._validate_value( + field_schema, + field_value, + path=self._join_path(path, str(field_name)), + ) return - if schema.get("type") == "object" and not isinstance(payload, dict): - raise AstrBotError.invalid_input("输入必须是 object") - properties = schema.get("properties", {}) - for field_name in schema.get("required", []): - if field_name not in payload: - raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}") - if payload[field_name] is None and not schema_allows_null( - properties.get(field_name) - ): - raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}") + + if schema_type == "array": + if not isinstance(value, list): + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 array" + ) + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for index, item in enumerate(value): + self._validate_value( + item_schema, + item, + path=self._index_path(path, index), + ) + return + + if schema_type == "string": + if not isinstance(value, str): + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 string" + ) + return + + if schema_type == "integer": + if not isinstance(value, int) or isinstance(value, bool): + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 integer" + ) + return + + if schema_type == "number": + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 number" + ) + return + + if schema_type == "boolean": + if not isinstance(value, bool): + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 boolean" + ) + return + + if schema_type == "null": + if value is not None: + raise AstrBotError.invalid_input( + f"{self._field_label(path)} 必须是 null" + ) + return + + @staticmethod + def _field_label(path: str) -> str: + if not path: + return "输入" + return f"字段 {path}" + + @staticmethod + def _join_path(path: str, field_name: str) -> str: + if not path: + return field_name + return f"{path}.{field_name}" + + @staticmethod + def _index_path(path: str, index: int) -> str: + return f"{path}[{index}]" if path else f"[{index}]" + + @staticmethod + def _property_schema( + properties: Any, + field_name: str, + ) -> dict[str, Any]: + if not isinstance(properties, dict): + return {} + field_schema = properties.get(field_name) + if isinstance(field_schema, dict): + return field_schema + return {} + + @staticmethod + def _schema_allows_null(field_schema: Any) -> bool: + if not isinstance(field_schema, dict): + return False + if field_schema.get("type") == "null": + return True + any_of = field_schema.get("anyOf") + if not isinstance(any_of, list): + return False + return any( + isinstance(candidate, dict) and candidate.get("type") == "null" + for candidate in any_of + ) diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index d3c59affc..08faec5d1 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -172,11 +172,15 @@ class HandlerDispatcher: if injected is None: if parameter.default is not parameter.empty: continue - logger.warning( - f"Handler '{handler.__name__}': 参数 '{parameter.name}' " - f"无法注入,将传入 None" + logger.error( + "Handler '{}' 的必填参数 '{}' 无法注入", + handler.__name__, + parameter.name, + ) + raise TypeError( + f"handler '{handler.__name__}' 的必填参数 " + f"'{parameter.name}' 无法注入" ) - injected_args.append(None) else: injected_args.append(injected) @@ -381,8 +385,10 @@ class CapabilityDispatcher: if injected is None: if parameter.default is not parameter.empty: continue - args.append(None) - continue + raise TypeError( + f"capability '{handler.__name__}' 的必填参数 " + f"'{parameter.name}' 无法注入" + ) args.append(injected) return args diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index 249686964..9a5418388 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -266,7 +266,7 @@ def _normalize_config_value(field_schema: dict[str, Any], value: Any) -> Any: return copy.deepcopy(value) if value is not None else default_value -def _load_plugin_config(plugin: PluginSpec) -> dict[str, Any]: +def load_plugin_config(plugin: PluginSpec) -> dict[str, Any]: """加载插件配置,返回普通字典。""" schema_path = plugin.plugin_dir / CONFIG_SCHEMA_FILE if not schema_path.exists(): diff --git a/src-new/astrbot_sdk/runtime/supervisor.py b/src-new/astrbot_sdk/runtime/supervisor.py index f9f82331a..718b8452c 100644 --- a/src-new/astrbot_sdk/runtime/supervisor.py +++ b/src-new/astrbot_sdk/runtime/supervisor.py @@ -54,6 +54,7 @@ from .loader import ( PluginEnvironmentManager, PluginSpec, discover_plugins, + load_plugin_config, ) from .peer import Peer from .transport import StdioTransport @@ -398,6 +399,24 @@ class SupervisorRuntime: self.skipped_plugins: dict[str, str] = {} self._register_internal_capabilities() + def _sync_plugin_registry(self, plugins: list[PluginSpec]) -> None: + loaded_plugin_set = set(self.loaded_plugins) + for plugin in plugins: + manifest = plugin.manifest_data + self.capability_router.upsert_plugin( + metadata={ + "name": plugin.name, + "display_name": str(manifest.get("display_name") or plugin.name), + "description": str( + manifest.get("desc") or manifest.get("description") or "" + ), + "author": str(manifest.get("author") or ""), + "version": str(manifest.get("version") or "0.0.0"), + "enabled": plugin.name in loaded_plugin_set, + }, + config=load_plugin_config(plugin), + ) + def _register_internal_capabilities(self) -> None: self.capability_router.register( CapabilityDescriptor( @@ -450,17 +469,71 @@ class SupervisorRuntime: session: WorkerSession, plugin_name: str, ) -> None: + """注册插件 capability,处理命名冲突。 + + 当 capability 名称冲突时: + - 如果是保留命名空间(handler/system/internal),跳过并警告 + - 否则,使用插件名作为前缀重新命名,例如: + - 插件 'my_plugin' 注册 'demo.echo' 冲突 + - 自动重命名为 'my_plugin.demo.echo' + """ capability_name = descriptor.name - if self.capability_router.contains(capability_name): - logger.warning( - "Capability 名称冲突:'{}' 已存在,跳过插件 '{}' 的注册。", - capability_name, - plugin_name, - # TODO: 更好的解决方案? + + if not self.capability_router.contains(capability_name): + # 无冲突,直接注册 + self._do_register_capability( + descriptor, session, capability_name, plugin_name ) return + + # 检查是否在保留命名空间内 + if capability_name.startswith(("handler.", "system.", "internal.")): + logger.warning( + "Capability '{}' 在保留命名空间内,跳过插件 '{}' 的注册。" + "保留命名空间不允许插件覆盖。", + capability_name, + plugin_name, + ) + return + + # 尝试添加插件前缀解决冲突 + prefixed_name = f"{plugin_name}.{capability_name}" + if self.capability_router.contains(prefixed_name): + logger.warning( + "Capability '{}' 和 '{}.{}' 均已存在," + "跳过插件 '{}' 的注册。请考虑使用更唯一的命名。", + capability_name, + plugin_name, + capability_name, + plugin_name, + ) + return + + # 使用前缀名称注册 + prefixed_descriptor = descriptor.model_copy(deep=True) + prefixed_descriptor.name = prefixed_name + logger.info( + "Capability '{}' 与已注册能力冲突,自动重命名为 '{}' (插件: {})。", + capability_name, + prefixed_name, + plugin_name, + ) + self._do_register_capability( + prefixed_descriptor, session, prefixed_name, plugin_name + ) + # 记录原始名称到前缀名称的映射,便于调试 + self._capability_sources[f"_original:{prefixed_name}"] = capability_name + + def _do_register_capability( + self, + descriptor: CapabilityDescriptor, + session: WorkerSession, + capability_name: str, + plugin_name: str, + ) -> None: + """实际执行 capability 注册。""" self.capability_router.register( - descriptor.model_copy(deep=True), + descriptor, call_handler=self._make_plugin_capability_caller(session, capability_name), stream_handler=( self._make_plugin_capability_streamer(session, capability_name) @@ -538,6 +611,7 @@ class SupervisorRuntime: self.skipped_plugins = dict(discovery.skipped_plugins) plan_result = self.env_manager.plan(discovery.plugins) self.skipped_plugins.update(plan_result.skipped_plugins) + self._sync_plugin_registry(discovery.plugins) try: planned_sessions: list[WorkerSession] = [] if plan_result.groups: @@ -596,6 +670,8 @@ class SupervisorRuntime: self._register_plugin_capability(descriptor, session, plugin_name) session.start_close_watch() + self._sync_plugin_registry(discovery.plugins) + aggregated_handlers = list(self.handler_to_worker.keys()) logger.info( "Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none" @@ -655,6 +731,8 @@ class SupervisorRuntime: if plugin_name in self.loaded_plugins: self.loaded_plugins.remove(plugin_name) self.plugin_to_worker_session.pop(plugin_name, None) + self.capability_router.set_plugin_enabled(plugin_name, False) + self.capability_router.remove_http_apis_for_plugin(plugin_name) stale_requests = [ request_id for request_id, active_session in self.active_requests.items() diff --git a/src-new/astrbot_sdk/testing.py b/src-new/astrbot_sdk/testing.py index d6a489ddd..c9f1631ce 100644 --- a/src-new/astrbot_sdk/testing.py +++ b/src-new/astrbot_sdk/testing.py @@ -2,7 +2,7 @@ `astrbot_sdk.testing` 是面向插件作者的稳定开发入口: -- `PluginHarness` 负责复用现有 loader / dispatcher / compat 执行链 +- `PluginHarness` 负责复用现有 loader / dispatcher 执行链 - `MockCapabilityRouter` 提供进程内 mock core 能力 - `MockPeer` 让 `Context` 客户端继续走真实的 capability 调用路径 - `StdoutPlatformSink` / `RecordedSend` 提供可观测的发送记录 @@ -22,11 +22,6 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, TextIO, get_type_hints -from ._legacy_runtime import ( - bind_legacy_runtime_contexts, - run_legacy_worker_shutdown_hooks, - run_legacy_worker_startup_hooks, -) from .context import CancelToken, Context as RuntimeContext from .errors import AstrBotError from .events import MessageEvent @@ -44,6 +39,7 @@ from .runtime.loader import ( LoadedPlugin, PluginSpec, load_plugin, + load_plugin_config, load_plugin_spec, ) from .star import Star @@ -408,6 +404,22 @@ class MockPeer: return f"local_{self._counter:04d}" +def _plugin_metadata_from_spec( + plugin: PluginSpec, + *, + enabled: bool, +) -> dict[str, Any]: + manifest = plugin.manifest_data + return { + "name": plugin.name, + "display_name": str(manifest.get("display_name") or plugin.name), + "description": str(manifest.get("desc") or manifest.get("description") or ""), + "author": str(manifest.get("author") or ""), + "version": str(manifest.get("version") or "0.0.0"), + "enabled": enabled, + } + + class MockContext(RuntimeContext): """直接用于 handler 单元测试的轻量运行时上下文。""" @@ -428,6 +440,17 @@ class MockContext(RuntimeContext): cancel_token=cancel_token, logger=logger, ) + self.router.upsert_plugin( + metadata={ + "name": plugin_id, + "display_name": plugin_id, + "description": "", + "author": "", + "version": "0.0.0", + "enabled": True, + }, + config={}, + ) self.llm = MockLLMClient(self.llm, self.router) self.platform = MockPlatformClient(self.platform, self.platform_sink) @@ -497,10 +520,10 @@ class LocalRuntimeConfig: class PluginHarness: """本地插件消息泵。 - 这里复用真实的 loader / dispatcher / compat 执行链,只负责: + 这里复用真实的 loader / dispatcher 执行链,只负责: - 在同一个事件循环里装配单插件运行时 - 维持本地 mock core 与发送记录 - - 把后续消息持续送入同一个 dispatcher/session_waiter 图 + - 把后续消息持续送入同一个 dispatcher """ def __init__( @@ -557,17 +580,12 @@ class PluginHarness: peer=self.peer, plugin_id=self.plugin.name, ) - bind_legacy_runtime_contexts( - [*self.loaded_plugin.handlers, *self.loaded_plugin.capabilities], - self.lifecycle_context, + self.router.upsert_plugin( + metadata=_plugin_metadata_from_spec(self.plugin, enabled=True), + config=load_plugin_config(self.plugin), ) try: await self._run_lifecycle("on_start") - await run_legacy_worker_startup_hooks( - [*self.loaded_plugin.handlers, *self.loaded_plugin.capabilities], - context=self.lifecycle_context, - metadata=dict(self.plugin.manifest_data), - ) except AstrBotError: raise except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖 @@ -582,13 +600,11 @@ class PluginHarness: ): return try: - await run_legacy_worker_shutdown_hooks( - [*self.loaded_plugin.handlers, *self.loaded_plugin.capabilities], - context=self.lifecycle_context, - metadata=dict(self.plugin.manifest_data), - ) await self._run_lifecycle("on_stop") finally: + if self.plugin is not None: + self.router.set_plugin_enabled(self.plugin.name, False) + self.router.remove_http_apis_for_plugin(self.plugin.name) self._started = False async def dispatch_text( @@ -880,7 +896,9 @@ class PluginHarness: event_payload, context=self.lifecycle_context, ) - session_waiters = self.dispatcher._session_waiters + session_waiters = getattr(self.dispatcher, "_session_waiters", None) + if session_waiters is None: + return False if hasattr(session_waiters, "has_waiter"): return session_waiters.has_waiter(probe_event) if isinstance(session_waiters, dict): diff --git a/test_plugin/new/commands/hello.py b/test_plugin/new/commands/hello.py index 71994527b..c647f2dc7 100644 --- a/test_plugin/new/commands/hello.py +++ b/test_plugin/new/commands/hello.py @@ -155,13 +155,21 @@ class HelloPlugin(Star): # Batch operations values = await ctx.db.get_many(["demo:key1", "demo:key2"]) - await ctx.db.set_many({ - "demo:batch1": {"batch": True}, - "demo:batch2": {"batch": True}, - }) + await ctx.db.set_many( + { + "demo:batch1": {"batch": True}, + "demo:batch2": {"batch": True}, + } + ) # Cleanup - for key in ["demo:key1", "demo:key2", "demo:key3", "demo:batch1", "demo:batch2"]: + for key in [ + "demo:key1", + "demo:key2", + "demo:key3", + "demo:batch1", + "demo:batch2", + ]: await ctx.db.delete(key) return event.plain_result( @@ -177,9 +185,7 @@ class HelloPlugin(Star): count = 0 async for change in ctx.db.watch("demo:"): count += 1 - await event.reply( - f"变更: {change['op']} {change['key']}" - ) + await event.reply(f"变更: {change['op']} {change['key']}") if count >= 3: break @@ -310,10 +316,7 @@ class HelloPlugin(Star): async def on_group_join(self, event: MessageEvent, ctx: Context) -> None: """Handle group join events.""" ctx.logger.info("用户加入群组: {}", event.user_id) - await ctx.platform.send( - event.session_id, - f"欢迎 {event.user_id} 加入群组!" - ) + await ctx.platform.send(event.session_id, f"欢迎 {event.user_id} 加入群组!") @on_event("group_leave") async def on_group_leave(self, event: MessageEvent, ctx: Context) -> None: diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py index de8a9118c..d0d7cb0b2 100644 --- a/tests_v4/test_capability_router.py +++ b/tests_v4/test_capability_router.py @@ -174,6 +174,16 @@ class TestCapabilityRouterInit: assert "platform.send_chain" in capability_names assert "platform.get_members" in capability_names + # HTTP capabilities + assert "http.register_api" in capability_names + assert "http.unregister_api" in capability_names + assert "http.list_apis" in capability_names + + # Metadata capabilities + assert "metadata.get_plugin" in capability_names + assert "metadata.list_plugins" in capability_names + assert "metadata.get_plugin_config" in capability_names + def test_builtin_descriptors_use_protocol_schema_registry(self): """CapabilityRouter should source built-in schemas from protocol constants.""" router = CapabilityRouter() @@ -1045,6 +1055,100 @@ class TestBuiltinPlatformCapabilities: assert result["members"][0]["user_id"] == "session-1:member-1" +class TestBuiltinHttpAndMetadataCapabilities: + """Tests for built-in HTTP and metadata capabilities.""" + + @pytest.mark.asyncio + async def test_http_register_and_list_apis(self): + router = CapabilityRouter() + token = CancelToken() + + await router.execute( + "http.register_api", + { + "plugin_id": "demo_plugin", + "route": "/demo", + "methods": ["GET", "POST"], + "handler_capability": "demo.http_handler", + "description": "demo", + }, + stream=False, + cancel_token=token, + request_id="req-http-1", + ) + + result = await router.execute( + "http.list_apis", + {"plugin_id": "demo_plugin"}, + stream=False, + cancel_token=token, + request_id="req-http-2", + ) + + assert result["apis"] == [ + { + "route": "/demo", + "methods": ["GET", "POST"], + "handler_capability": "demo.http_handler", + "description": "demo", + "plugin_id": "demo_plugin", + } + ] + + @pytest.mark.asyncio + async def test_metadata_get_plugin_and_config(self): + router = CapabilityRouter() + token = CancelToken() + router.upsert_plugin( + metadata={ + "name": "demo_plugin", + "display_name": "Demo Plugin", + "description": "demo", + "author": "tester", + "version": "0.1.0", + "enabled": True, + }, + config={"debug": True}, + ) + + plugin_result = await router.execute( + "metadata.get_plugin", + {"plugin_id": "demo_plugin", "name": "demo_plugin"}, + stream=False, + cancel_token=token, + request_id="req-meta-1", + ) + config_result = await router.execute( + "metadata.get_plugin_config", + {"plugin_id": "demo_plugin", "name": "demo_plugin"}, + stream=False, + cancel_token=token, + request_id="req-meta-2", + ) + + assert plugin_result["plugin"]["display_name"] == "Demo Plugin" + assert config_result["config"] == {"debug": True} + + @pytest.mark.asyncio + async def test_metadata_get_plugin_config_rejects_other_plugin(self): + router = CapabilityRouter() + token = CancelToken() + router.upsert_plugin( + metadata={"name": "demo_plugin"}, + config={"secret": True}, + ) + + result = await router.execute( + "metadata.get_plugin_config", + {"plugin_id": "other_plugin", "name": "demo_plugin"}, + stream=False, + cancel_token=token, + request_id="req-meta-3", + ) + + assert result == {"config": None} + + class TestValidateSchema: """Tests for _validate_schema method.""" @@ -1085,3 +1189,38 @@ class TestValidateSchema: {"type": "object", "required": ["name"]}, {"name": None}, ) + + @pytest.mark.asyncio + async def test_type_mismatch_raises(self): + """_validate_schema should reject mismatched scalar types.""" + router = CapabilityRouter() + + with pytest.raises(AstrBotError, match="字段 count 必须是 integer"): + router._validate_schema( + { + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + }, + {"count": "bad"}, + ) + + @pytest.mark.asyncio + async def test_array_item_type_mismatch_raises(self): + """_validate_schema should validate nested array items.""" + router = CapabilityRouter() + + with pytest.raises(AstrBotError, match=r"字段 keys\[1\] 必须是 string"): + router._validate_schema( + { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": {"type": "string"}, + } + }, + "required": ["keys"], + }, + {"keys": ["ok", 1]}, + ) diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py new file mode 100644 index 000000000..cce28bcdd --- /dev/null +++ b/tests_v4/test_handler_dispatcher.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from astrbot_sdk.context import CancelToken, Context +from astrbot_sdk.events import MessageEvent +from astrbot_sdk.protocol.descriptors import ( + CapabilityDescriptor, + HandlerDescriptor, + MessageTrigger, +) +from astrbot_sdk.runtime.handler_dispatcher import ( + CapabilityDispatcher, + HandlerDispatcher, +) +from astrbot_sdk.runtime.loader import LoadedCapability, LoadedHandler +from astrbot_sdk.testing import MockCapabilityRouter, MockPeer + + +def _peer(): + return MockPeer(MockCapabilityRouter()) + + +class TestHandlerDispatcherArgumentValidation: + def test_handler_dispatcher_raises_for_uninjectable_required_param(self): + peer = _peer() + dispatcher = HandlerDispatcher(plugin_id="demo", peer=peer, handlers=[]) + ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken()) + event = MessageEvent(text="hello", session_id="s1", context=ctx) + + async def bad_handler(event: MessageEvent, missing: str) -> None: + return None + + with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + dispatcher._build_args(bad_handler, event, ctx, args={}) + + def test_capability_dispatcher_raises_for_uninjectable_required_param(self): + peer = _peer() + capability = LoadedCapability( + descriptor=CapabilityDescriptor(name="demo.cap", description="demo"), + callable=lambda ctx, missing: {"ok": True}, + owner=object(), + plugin_id="demo", + ) + dispatcher = CapabilityDispatcher( + plugin_id="demo", + peer=peer, + capabilities=[capability], + ) + ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken()) + + with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + dispatcher._build_args( + capability.callable, + payload={}, + ctx=ctx, + cancel_token=CancelToken(), + ) + + +class TestHandlerDispatcherInvoke: + @pytest.mark.asyncio + async def test_invoke_reports_missing_injected_param(self): + peer = _peer() + + async def bad_handler(event: MessageEvent, missing: str) -> None: + return None + + loaded = LoadedHandler( + descriptor=HandlerDescriptor( + id="demo:plugin.bad_handler", + trigger=MessageTrigger(), + ), + callable=bad_handler, + owner=object(), + plugin_id="demo", + ) + dispatcher = HandlerDispatcher(plugin_id="demo", peer=peer, handlers=[loaded]) + + class Message: + id = "req-1" + input = { + "handler_id": "demo:plugin.bad_handler", + "event": {"text": "hello", "session_id": "s1"}, + } + + with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"): + await dispatcher.invoke(Message(), CancelToken()) diff --git a/tests_v4/test_http_metadata_clients.py b/tests_v4/test_http_metadata_clients.py index b0c84960e..7704fe979 100644 --- a/tests_v4/test_http_metadata_clients.py +++ b/tests_v4/test_http_metadata_clients.py @@ -201,7 +201,7 @@ class TestMetadataClient: mock_proxy.call.assert_called_once_with( "metadata.get_plugin_config", - {"name": "current_plugin"}, + {"plugin_id": "current_plugin", "name": "current_plugin"}, ) assert result == {"key": "value"} @@ -236,6 +236,34 @@ class TestMetadataClient: assert result is not None assert result.name == "current_plugin" + @pytest.mark.asyncio + async def test_get_plugin_includes_caller_plugin_id( + self, metadata_client, mock_proxy + ): + """Metadata requests should carry current plugin identity for routing/auth.""" + mock_proxy.call.return_value = {"plugin": None} + + await metadata_client.get_plugin("other_plugin") + + mock_proxy.call.assert_called_once_with( + "metadata.get_plugin", + {"plugin_id": "current_plugin", "name": "other_plugin"}, + ) + + @pytest.mark.asyncio + async def test_list_plugins_includes_caller_plugin_id( + self, metadata_client, mock_proxy + ): + """list_plugins should include current plugin identity.""" + mock_proxy.call.return_value = {"plugins": []} + + await metadata_client.list_plugins() + + mock_proxy.call.assert_called_once_with( + "metadata.list_plugins", + {"plugin_id": "current_plugin"}, + ) + class TestPluginMetadata: """Tests for PluginMetadata dataclass.""" diff --git a/tests_v4/test_memory_client.py b/tests_v4/test_memory_client.py index d5d6f4a6b..c8aa54f17 100644 --- a/tests_v4/test_memory_client.py +++ b/tests_v4/test_memory_client.py @@ -233,3 +233,149 @@ class TestMemoryClientDelete: await client.delete("") proxy.call.assert_called_once_with("memory.delete", {"key": ""}) + + +class TestMemoryClientSaveWithTTL: + """Tests for MemoryClient.save_with_ttl() method.""" + + @pytest.mark.asyncio + async def test_save_with_ttl_calls_proxy(self): + """save_with_ttl() should call proxy with key, value, and ttl_seconds.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save_with_ttl("temp_key", {"data": "value"}, ttl_seconds=3600) + + proxy.call.assert_called_once_with( + "memory.save_with_ttl", + {"key": "temp_key", "value": {"data": "value"}, "ttl_seconds": 3600}, + ) + + @pytest.mark.asyncio + async def test_save_with_ttl_raises_type_error_for_non_dict(self): + """save_with_ttl() should raise TypeError for non-dict value.""" + proxy = AsyncMock(spec=CapabilityProxy) + client = MemoryClient(proxy) + + with pytest.raises( + TypeError, match="memory.save_with_ttl 的 value 必须是 dict" + ): + await client.save_with_ttl("key", "not a dict", ttl_seconds=60) + + @pytest.mark.asyncio + async def test_save_with_ttl_raises_value_error_for_invalid_ttl(self): + """save_with_ttl() should raise ValueError for ttl_seconds < 1.""" + proxy = AsyncMock(spec=CapabilityProxy) + client = MemoryClient(proxy) + + with pytest.raises(ValueError, match="ttl_seconds 必须大于 0"): + await client.save_with_ttl("key", {"data": 1}, ttl_seconds=0) + + with pytest.raises(ValueError, match="ttl_seconds 必须大于 0"): + await client.save_with_ttl("key", {"data": 1}, ttl_seconds=-1) + + +class TestMemoryClientGetMany: + """Tests for MemoryClient.get_many() method.""" + + @pytest.mark.asyncio + async def test_get_many_returns_items(self): + """get_many() should return list of items with key and value.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock( + return_value={ + "items": [ + {"key": "k1", "value": {"a": 1}}, + {"key": "k2", "value": {"b": 2}}, + ] + } + ) + + client = MemoryClient(proxy) + result = await client.get_many(["k1", "k2"]) + + proxy.call.assert_called_once_with("memory.get_many", {"keys": ["k1", "k2"]}) + assert len(result) == 2 + assert result[0]["key"] == "k1" + assert result[0]["value"] == {"a": 1} + + @pytest.mark.asyncio + async def test_get_many_returns_empty_list_for_malformed_response(self): + """get_many() should return empty list for malformed response.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"items": "not a list"}) + + client = MemoryClient(proxy) + result = await client.get_many(["k1", "k2"]) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_many_returns_empty_list_for_missing_items(self): + """get_many() should return empty list when items key missing.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + result = await client.get_many(["k1"]) + + assert result == [] + + +class TestMemoryClientDeleteMany: + """Tests for MemoryClient.delete_many() method.""" + + @pytest.mark.asyncio + async def test_delete_many_returns_count(self): + """delete_many() should return number of deleted items.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"deleted_count": 3}) + + client = MemoryClient(proxy) + result = await client.delete_many(["k1", "k2", "k3"]) + + proxy.call.assert_called_once_with( + "memory.delete_many", {"keys": ["k1", "k2", "k3"]} + ) + assert result == 3 + + @pytest.mark.asyncio + async def test_delete_many_returns_zero_for_missing_count(self): + """delete_many() should return 0 when deleted_count missing.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + result = await client.delete_many(["k1"]) + + assert result == 0 + + +class TestMemoryClientStats: + """Tests for MemoryClient.stats() method.""" + + @pytest.mark.asyncio + async def test_stats_returns_total_items(self): + """stats() should return total_items count.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"total_items": 42, "total_bytes": 1024}) + + client = MemoryClient(proxy) + result = await client.stats() + + proxy.call.assert_called_once_with("memory.stats", {}) + assert result["total_items"] == 42 + assert result["total_bytes"] == 1024 + + @pytest.mark.asyncio + async def test_stats_defaults_to_zero(self): + """stats() should default total_items to 0 if missing.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + result = await client.stats() + + assert result["total_items"] == 0 + assert result["total_bytes"] is None diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py new file mode 100644 index 000000000..200fd9bec --- /dev/null +++ b/tests_v4/test_testing_module.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _source_env() -> dict[str, str]: + env = os.environ.copy() + src_new = str(_repo_root() / "src-new") + current = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{src_new}{os.pathsep}{current}" if current else src_new + return env + + +def test_testing_module_importable() -> None: + from astrbot_sdk import testing + + assert testing.PluginHarness is not None + assert testing.MockContext is not None + + +def test_cli_help_works_from_source_tree() -> None: + process = subprocess.run( + [sys.executable, "-m", "astrbot_sdk", "--help"], + capture_output=True, + text=True, + check=False, + env=_source_env(), + ) + + assert process.returncode == 0, process.stderr + assert "Usage" in process.stdout + + +@pytest.mark.asyncio +async def test_plugin_harness_dispatches_sample_plugin() -> None: + from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness + + plugin_dir = _repo_root() / "test_plugin" / "new" + + async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness: + records = await harness.dispatch_text("hello") + + assert any(record.text == "Echo: hello" for record in records) + + +@pytest.mark.asyncio +async def test_plugin_harness_supports_metadata_and_http_commands() -> None: + from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness + + plugin_dir = _repo_root() / "test_plugin" / "new" + + async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness: + plugin_records = await harness.dispatch_text("plugins") + api_records = await harness.dispatch_text("register_api") + + assert any( + "astrbot_plugin_v4demo" in (record.text or "") for record in plugin_records + ) + assert any( + "已注册 API,当前共 1 个" in (record.text or "") for record in api_records + )