diff --git a/AGENTS.md b/AGENTS.md index 2272b02cc..edd9170f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,14 @@ # CLAUDE 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: `src/astrbot_sdk/tests/start_client.py` and `benchmark_8_plugins_resource_usage.py` still reference legacy `astrbot_sdk.runtime.galaxy`, but `src-new/astrbot_sdk/runtime/galaxy.py` no longer exists. Treat `tests_v4/test_script_migrations.py` as the maintained replacement instead of reviving the old Galaxy path. - 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: `Peer` had an early-cancel race for inbound invokes: if `CancelMessage` arrived before the invoke task executed its first line, the task could be cancelled before sending any terminal event, leaving the caller's stream iterator waiting forever. Preserve a per-request start event and pre-check the cancel token at the top of `_handle_invoke`. - 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: 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: `WorkerSession` cannot assume the caller-provided `repo_root` contains `src-new/astrbot_sdk`. Tests and external bootstraps may pass a temporary repo root while still expecting the in-tree SDK package to launch worker subprocesses via `python -m astrbot_sdk`. Resolve the SDK source directory from the real package location when the supplied root does not contain it. -- 2026-03-13: `MemoryClient.get()` is part of the supported v4 client surface and must stay in sync with `CapabilityRouter` built-ins. The client method existed while the router forgot to register `memory.get`, which caused a real runtime gap hidden by API shape alone. - 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: `load_plugin()` must not blindly `getattr()` every name from `dir(instance)` during handler discovery. Real plugins may expose properties or descriptors with side effects or exceptions; inspect attributes statically first, and only bind names that actually carry handler metadata. -- 2026-03-13: In `Peer`, “remote initialized” and “transport still alive” are separate states. Waiting for initialization must fail when the connection closes first, and malformed inbound protocol messages should actively fail pending calls instead of leaving futures/streams hanging. -- 2026-03-13: Several first-layer files under `src-new/astrbot_sdk/*.py` carried stale migration comparison blocks that claimed missing CLI help, missing compat APIs, or other gaps already covered by tests and current implementations. Treat those comments as historical noise; verify behavior against code and tests before "restoring" features from the comments. -- 2026-03-13: The v4 design already defines built-in capability schema governance and reserved namespaces at the protocol layer. Keeping anonymous schema builders only inside `runtime/capability_router.py` drifts runtime behavior away from the protocol contract; centralize built-in schemas and namespace constants in `protocol/descriptors.py`. -- 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 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. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 2272b02cc..edd9170f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,22 +1,14 @@ # CLAUDE 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: `src/astrbot_sdk/tests/start_client.py` and `benchmark_8_plugins_resource_usage.py` still reference legacy `astrbot_sdk.runtime.galaxy`, but `src-new/astrbot_sdk/runtime/galaxy.py` no longer exists. Treat `tests_v4/test_script_migrations.py` as the maintained replacement instead of reviving the old Galaxy path. - 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: `Peer` had an early-cancel race for inbound invokes: if `CancelMessage` arrived before the invoke task executed its first line, the task could be cancelled before sending any terminal event, leaving the caller's stream iterator waiting forever. Preserve a per-request start event and pre-check the cancel token at the top of `_handle_invoke`. - 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: 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: `WorkerSession` cannot assume the caller-provided `repo_root` contains `src-new/astrbot_sdk`. Tests and external bootstraps may pass a temporary repo root while still expecting the in-tree SDK package to launch worker subprocesses via `python -m astrbot_sdk`. Resolve the SDK source directory from the real package location when the supplied root does not contain it. -- 2026-03-13: `MemoryClient.get()` is part of the supported v4 client surface and must stay in sync with `CapabilityRouter` built-ins. The client method existed while the router forgot to register `memory.get`, which caused a real runtime gap hidden by API shape alone. - 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: `load_plugin()` must not blindly `getattr()` every name from `dir(instance)` during handler discovery. Real plugins may expose properties or descriptors with side effects or exceptions; inspect attributes statically first, and only bind names that actually carry handler metadata. -- 2026-03-13: In `Peer`, “remote initialized” and “transport still alive” are separate states. Waiting for initialization must fail when the connection closes first, and malformed inbound protocol messages should actively fail pending calls instead of leaving futures/streams hanging. -- 2026-03-13: Several first-layer files under `src-new/astrbot_sdk/*.py` carried stale migration comparison blocks that claimed missing CLI help, missing compat APIs, or other gaps already covered by tests and current implementations. Treat those comments as historical noise; verify behavior against code and tests before "restoring" features from the comments. -- 2026-03-13: The v4 design already defines built-in capability schema governance and reserved namespaces at the protocol layer. Keeping anonymous schema builders only inside `runtime/capability_router.py` drifts runtime behavior away from the protocol contract; centralize built-in schemas and namespace constants in `protocol/descriptors.py`. -- 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 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. # 开发命令 diff --git a/src-new/astrbot_sdk/_legacy_api.py b/src-new/astrbot_sdk/_legacy_api.py index 28344d91f..6844fd140 100644 --- a/src-new/astrbot_sdk/_legacy_api.py +++ b/src-new/astrbot_sdk/_legacy_api.py @@ -485,9 +485,23 @@ class LegacyContext: ) async def send_message(self, session: str, message_chain: Any) -> None: - _warn_once("context.send_message()", "ctx.platform.send(session, text)") + _warn_once( + "context.send_message()", + "ctx.platform.send(...) / ctx.platform.send_chain(...)", + ) ctx = self.require_runtime_context() - # 旧版插件常传 MessageChain 或类似对象,compat 层统一收口到纯文本发送。 + chain = getattr(message_chain, "chain", None) + to_payload = getattr(message_chain, "to_payload", None) + is_plain_text_only = getattr(message_chain, "is_plain_text_only", None) + if ( + isinstance(chain, list) + and callable(to_payload) + and not (callable(is_plain_text_only) and is_plain_text_only()) + ): + await ctx.platform.send_chain(session, to_payload()) + return + + # 旧版插件也可能传纯文本对象,compat 层保留文本兜底。 if hasattr(message_chain, "get_plain_text") and callable( message_chain.get_plain_text ): @@ -522,7 +536,24 @@ class LegacyContext: await ctx.db.delete(key) -class CommandComponent(Star): +class LegacyStar(Star): + """旧版 ``astrbot.api.star.Star`` 兼容基类。""" + + def __init__(self, context: LegacyContext | None = None, config: Any | None = None): + self.context = context + if config is not None: + self.config = config + + @classmethod + def __astrbot_is_new_star__(cls) -> bool: + return False + + @classmethod + def _astrbot_create_legacy_context(cls, plugin_id: str) -> LegacyContext: + return LegacyContext(plugin_id) + + +class CommandComponent(LegacyStar): @classmethod def __astrbot_is_new_star__(cls) -> bool: return False @@ -533,6 +564,38 @@ class CommandComponent(Star): return LegacyContext(plugin_id) +def register( + name: str | None = None, + author: str | None = None, + desc: str | None = None, + version: str | None = None, + repo: str | None = None, +): + """旧版插件元数据装饰器兼容入口。""" + + metadata = { + "name": name, + "author": author, + "desc": desc, + "version": version, + "repo": repo, + } + + def decorator(cls): + existing = getattr(cls, "__astrbot_plugin_metadata__", {}) + setattr( + cls, + "__astrbot_plugin_metadata__", + { + **existing, + **{key: value for key, value in metadata.items() if value is not None}, + }, + ) + return cls + + return decorator + + Context = LegacyContext __all__ = [ @@ -540,5 +603,7 @@ __all__ = [ "Context", "LegacyContext", "LegacyConversationManager", + "LegacyStar", "MIGRATION_DOC_URL", + "register", ] diff --git a/src-new/astrbot_sdk/api/__init__.py b/src-new/astrbot_sdk/api/__init__.py index e3594bcb8..c17a6afc1 100644 --- a/src-new/astrbot_sdk/api/__init__.py +++ b/src-new/astrbot_sdk/api/__init__.py @@ -30,13 +30,23 @@ - 不复制独立运行时逻辑,保持架构清晰 """ -from . import basic, components, event, message, platform, provider, star +from . import ( + basic, + components, + event, + message, + message_components, + platform, + provider, + star, +) __all__ = [ "basic", "components", "event", "message", + "message_components", "platform", "provider", "star", diff --git a/src-new/astrbot_sdk/api/basic/astrbot_config.py b/src-new/astrbot_sdk/api/basic/astrbot_config.py index ea0bf67f2..0fd6a0c11 100644 --- a/src-new/astrbot_sdk/api/basic/astrbot_config.py +++ b/src-new/astrbot_sdk/api/basic/astrbot_config.py @@ -1,8 +1,43 @@ """旧版配置对象兼容类型。""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + class AstrBotConfig(dict): """兼容旧版 ``AstrBotConfig``。 - 旧版实现本身就是 ``dict`` 的薄封装,兼容层保持这一行为。 + 旧版实现本身就是 ``dict`` 的薄封装。compat 层额外补上 + ``save_config()``,以支持文档里的插件配置用法。 """ + + def __init__( + self, + *args: Any, + save_path: str | Path | None = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._save_path = Path(save_path) if save_path is not None else None + + @property + def save_path(self) -> Path | None: + return self._save_path + + def bind_save_path(self, save_path: str | Path | None) -> "AstrBotConfig": + self._save_path = Path(save_path) if save_path is not None else None + return self + + def save_config(self, save_path: str | Path | None = None) -> None: + path = Path(save_path) if save_path is not None else self._save_path + if path is None: + raise RuntimeError("AstrBotConfig 未绑定保存路径") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(self, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + self._save_path = path diff --git a/src-new/astrbot_sdk/api/event/astr_message_event.py b/src-new/astrbot_sdk/api/event/astr_message_event.py index 85a99d136..7db6ee6ac 100644 --- a/src-new/astrbot_sdk/api/event/astr_message_event.py +++ b/src-new/astrbot_sdk/api/event/astr_message_event.py @@ -169,6 +169,7 @@ class AstrMessageEvent(MessageEvent): platform=event.platform, session_id=event.session_id, raw=event.raw, + context=getattr(event, "_context", None), reply_handler=getattr(event, "_reply_handler", None), ) @@ -321,6 +322,13 @@ class AstrMessageEvent(MessageEvent): async def send(self, message: MessageChain) -> None: self.has_send_oper = True + runtime_context = getattr(self, "_context", None) + if runtime_context is not None and not message.is_plain_text_only(): + await runtime_context.platform.send_chain( + self.session_id, + message.to_payload(), + ) + return await self.reply(message.get_plain_text()) async def react(self, emoji: str) -> None: diff --git a/src-new/astrbot_sdk/api/message/__init__.py b/src-new/astrbot_sdk/api/message/__init__.py index 6cbde2a76..2e54ca35e 100644 --- a/src-new/astrbot_sdk/api/message/__init__.py +++ b/src-new/astrbot_sdk/api/message/__init__.py @@ -1,11 +1,14 @@ """旧版 ``astrbot_sdk.api.message`` 的兼容入口。""" +from . import components as Comp from .chain import MessageChain from .components import ( At, AtAll, BaseMessageComponent, + ComponentTypes, ComponentType, + CompT, Contact, Dice, Face, @@ -33,7 +36,10 @@ __all__ = [ "At", "AtAll", "BaseMessageComponent", + "Comp", + "ComponentTypes", "ComponentType", + "CompT", "Contact", "Dice", "Face", diff --git a/src-new/astrbot_sdk/api/message/chain.py b/src-new/astrbot_sdk/api/message/chain.py index 6b64e0b95..f00a10f78 100644 --- a/src-new/astrbot_sdk/api/message/chain.py +++ b/src-new/astrbot_sdk/api/message/chain.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any from . import components as Comp @@ -45,6 +46,38 @@ class MessageChain: self.use_t2i_ = use_t2i return self + def to_payload(self) -> list[dict[str, Any]]: + payload: list[dict[str, Any]] = [] + for component in self.chain: + if isinstance(component, dict): + payload.append(dict(component)) + continue + to_dict = getattr(component, "to_dict", None) + if callable(to_dict): + payload.append(to_dict()) + continue + model_dump = getattr(component, "model_dump", None) + if callable(model_dump): + payload.append(model_dump()) + continue + payload.append({"type": "Unknown", "text": str(component)}) + return payload + + def is_plain_text_only(self) -> bool: + if not self.chain: + return False + for component in self.chain: + if isinstance(component, Comp.Plain): + continue + if isinstance(component, dict) and str(component.get("type")) in { + "Plain", + "plain", + "text", + }: + continue + return False + return True + def get_plain_text(self) -> str: return " ".join( component.text diff --git a/src-new/astrbot_sdk/api/message_components.py b/src-new/astrbot_sdk/api/message_components.py new file mode 100644 index 000000000..ed66b2c3f --- /dev/null +++ b/src-new/astrbot_sdk/api/message_components.py @@ -0,0 +1,61 @@ +"""旧版 ``astrbot_sdk.api.message_components`` 的兼容导出。""" + +from .message.components import ( + At, + AtAll, + BaseMessageComponent, + ComponentTypes, + ComponentType, + CompT, + Contact, + Dice, + Face, + File, + Forward, + Image, + Json, + Location, + Music, + Node, + Nodes, + Plain, + Poke, + Record, + Reply, + RPS, + Shake, + Share, + Unknown, + Video, + WechatEmoji, +) + +__all__ = [ + "At", + "AtAll", + "BaseMessageComponent", + "ComponentTypes", + "ComponentType", + "CompT", + "Contact", + "Dice", + "Face", + "File", + "Forward", + "Image", + "Json", + "Location", + "Music", + "Node", + "Nodes", + "Plain", + "Poke", + "Record", + "Reply", + "RPS", + "Shake", + "Share", + "Unknown", + "Video", + "WechatEmoji", +] diff --git a/src-new/astrbot_sdk/api/star/__init__.py b/src-new/astrbot_sdk/api/star/__init__.py index 2beb8dae5..6efda3445 100644 --- a/src-new/astrbot_sdk/api/star/__init__.py +++ b/src-new/astrbot_sdk/api/star/__init__.py @@ -1,6 +1,7 @@ """旧版 ``astrbot_sdk.api.star`` 的兼容入口。""" +from ..._legacy_api import LegacyStar as Star, register from .context import Context from .star import StarMetadata -__all__ = ["Context", "StarMetadata"] +__all__ = ["Context", "Star", "StarMetadata", "register"] diff --git a/src-new/astrbot_sdk/clients/platform.py b/src-new/astrbot_sdk/clients/platform.py index 7d1710221..338d73e20 100644 --- a/src-new/astrbot_sdk/clients/platform.py +++ b/src-new/astrbot_sdk/clients/platform.py @@ -5,7 +5,8 @@ 设计边界: - `PlatformClient` 只负责直接的平台 capability - 旧版 `send_message(session, MessageChain)` 兼容由 `_legacy_api.py` 承接 - - 富消息链构建能力位于 `api.message` compat 子模块,而不是此客户端 + - 富消息链通过 `platform.send_chain` 发送,链构建能力位于 `api.message` + compat 子模块,而不是此客户端 """ from __future__ import annotations @@ -76,6 +77,25 @@ class PlatformClient: {"session": session, "image_url": image_url}, ) + async def send_chain( + self, + session: str, + chain: list[dict[str, Any]], + ) -> dict[str, Any]: + """发送富消息链。 + + Args: + session: 统一消息来源标识 (UMO) + chain: 序列化后的消息组件数组 + + Returns: + 发送结果 + """ + return await self._proxy.call( + "platform.send_chain", + {"session": session, "chain": chain}, + ) + async def get_members(self, session: str) -> list[dict[str, Any]]: """获取群组成员列表。 diff --git a/src-new/astrbot_sdk/events.py b/src-new/astrbot_sdk/events.py index 3f12ac9cd..87ea49ae7 100644 --- a/src-new/astrbot_sdk/events.py +++ b/src-new/astrbot_sdk/events.py @@ -42,6 +42,7 @@ class MessageEvent: self.platform = platform self.session_id = session_id or group_id or user_id or "" self.raw = raw or {} + self._context = context self._reply_handler = reply_handler if self._reply_handler is None and context is not None: self._reply_handler = lambda text: context.platform.send( diff --git a/src-new/astrbot_sdk/protocol/descriptors.py b/src-new/astrbot_sdk/protocol/descriptors.py index f87078978..12a50a9fe 100644 --- a/src-new/astrbot_sdk/protocol/descriptors.py +++ b/src-new/astrbot_sdk/protocol/descriptors.py @@ -145,6 +145,15 @@ PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA = _object_schema( required=("message_id",), message_id={"type": "string"}, ) +PLATFORM_SEND_CHAIN_INPUT_SCHEMA = _object_schema( + required=("session", "chain"), + session={"type": "string"}, + chain={"type": "array", "items": {"type": "object"}}, +) +PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA = _object_schema( + required=("message_id",), + message_id={"type": "string"}, +) PLATFORM_GET_MEMBERS_INPUT_SCHEMA = _object_schema( required=("session",), session={"type": "string"}, @@ -207,6 +216,10 @@ BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { "input": PLATFORM_SEND_IMAGE_INPUT_SCHEMA, "output": PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA, }, + "platform.send_chain": { + "input": PLATFORM_SEND_CHAIN_INPUT_SCHEMA, + "output": PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA, + }, "platform.get_members": { "input": PLATFORM_GET_MEMBERS_INPUT_SCHEMA, "output": PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA, @@ -443,6 +456,8 @@ __all__ = [ "MessageTrigger", "PLATFORM_GET_MEMBERS_INPUT_SCHEMA", "PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA", + "PLATFORM_SEND_CHAIN_INPUT_SCHEMA", + "PLATFORM_SEND_CHAIN_OUTPUT_SCHEMA", "PLATFORM_SEND_IMAGE_INPUT_SCHEMA", "PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA", "PLATFORM_SEND_INPUT_SCHEMA", diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py index 2d53320ba..e02788567 100644 --- a/src-new/astrbot_sdk/runtime/capability_router.py +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -23,6 +23,7 @@ db.list: 列出 KV 键 platform.send: 发送消息 platform.send_image: 发送图片 + platform.send_chain: 发送消息链 platform.get_members: 获取群成员 与旧版对比: @@ -327,6 +328,27 @@ class CapabilityRouter: ) return {"message_id": message_id} + async def platform_send_chain( + _request_id: str, payload: dict[str, Any], _token + ) -> dict[str, Any]: + session = str(payload.get("session", "")) + chain = payload.get("chain") + if not isinstance(chain, list) or not all( + isinstance(item, dict) for item in chain + ): + raise AstrBotError.invalid_input( + "platform.send_chain 的 chain 必须是 object 数组" + ) + message_id = f"chain_{len(self.sent_messages) + 1}" + self.sent_messages.append( + { + "message_id": message_id, + "session": session, + "chain": [dict(item) for item in chain], + } + ) + return {"message_id": message_id} + async def platform_get_members( _request_id: str, payload: dict[str, Any], _token ) -> dict[str, Any]: @@ -398,6 +420,10 @@ class CapabilityRouter: builtin_descriptor("platform.send_image", "发送图片"), call_handler=platform_send_image, ) + self.register( + builtin_descriptor("platform.send_chain", "发送消息链"), + call_handler=platform_send_chain, + ) self.register( builtin_descriptor("platform.get_members", "获取群成员"), call_handler=platform_get_members, diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index a8501c53a..a14c39c13 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -93,7 +93,7 @@ class HandlerDispatcher: ctx = Context( peer=self._peer, plugin_id=self._plugin_id, cancel_token=cancel_token ) - event = MessageEvent.from_payload(message.input.get("event", {})) + event = MessageEvent.from_payload(message.input.get("event", {}), context=ctx) event.bind_reply_handler(self._create_reply_handler(ctx, event)) if loaded.legacy_context is not None: loaded.legacy_context.bind_runtime_context(ctx) @@ -144,12 +144,12 @@ class HandlerDispatcher: ) if inspect.isasyncgen(result): async for item in result: - await self._consume_legacy_result(item, event) + await self._consume_legacy_result(item, event, ctx) return if inspect.isawaitable(result): result = await result if result is not None: - await self._consume_legacy_result(result, event) + await self._consume_legacy_result(result, event, ctx) except Exception as exc: await self._handle_error(loaded.owner, exc, event, ctx) raise @@ -272,10 +272,27 @@ class HandlerDispatcher: return None - async def _consume_legacy_result(self, item: Any, event: MessageEvent) -> None: + async def _consume_legacy_result( + self, + item: Any, + event: MessageEvent, + ctx: Context | None = None, + ) -> None: from ..api.event.event_result import MessageEventResult + from ..api.message.chain import MessageChain if isinstance(item, MessageEventResult): + if item.chain and ctx is not None and not item.is_plain_text_only(): + await ctx.platform.send_chain(event.session_id, item.to_payload()) + return + plain_text = item.get_plain_text() + if plain_text: + await event.reply(plain_text) + return + if isinstance(item, MessageChain): + if item.chain and ctx is not None and not item.is_plain_text_only(): + await ctx.platform.send_chain(event.session_id, item.to_payload()) + return plain_text = item.get_plain_text() if plain_text: await event.reply(plain_text) diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index 45f5b1ea8..cbd7611bc 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -84,6 +84,8 @@ legacy 兼容也集中放在这里,尤其是“同一插件共享一个 `Legac from __future__ import annotations +import copy +import importlib.util import json import inspect import os @@ -98,11 +100,22 @@ from typing import Any import yaml +from ..api.basic import AstrBotConfig from ..decorators import get_handler_meta from ..protocol.descriptors import HandlerDescriptor from ..star import Star STATE_FILE_NAME = ".astrbot-worker-state.json" +PLUGIN_MANIFEST_FILE = "plugin.yaml" +LEGACY_METADATA_FILE = "metadata.yaml" +LEGACY_MAIN_FILE = "main.py" +CONFIG_SCHEMA_FILE = "_conf_schema.json" +LEGACY_MAIN_MANIFEST_KEY = "__legacy_main__" +PLUGIN_METADATA_ATTR = "__astrbot_plugin_metadata__" + + +def _default_python_version() -> str: + return f"{sys.version_info.major}.{sys.version_info.minor}" def _venv_python_path(venv_dir: Path) -> Path: @@ -186,15 +199,221 @@ def _resolve_handler_candidate(instance: Any, name: str) -> tuple[Any, Any] | No return None +def _read_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return data if isinstance(data, dict) else {} + + +def _read_requirements_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + +def _looks_like_legacy_plugin(plugin_dir: Path) -> bool: + return ( + not (plugin_dir / PLUGIN_MANIFEST_FILE).exists() + and (plugin_dir / LEGACY_MAIN_FILE).exists() + ) + + +def _build_legacy_manifest(plugin_dir: Path) -> tuple[Path, dict[str, Any]]: + metadata_path = plugin_dir / LEGACY_METADATA_FILE + metadata = _read_yaml(metadata_path) if metadata_path.exists() else {} + plugin_name = str(metadata.get("name") or plugin_dir.name) + manifest_data: dict[str, Any] = { + "name": plugin_name, + "author": metadata.get("author"), + "desc": metadata.get("desc") or metadata.get("description"), + "version": metadata.get("version"), + "repo": metadata.get("repo"), + "display_name": metadata.get("display_name"), + "runtime": {"python": _default_python_version()}, + "components": [], + LEGACY_MAIN_MANIFEST_KEY: True, + } + return ( + metadata_path if metadata_path.exists() else plugin_dir / LEGACY_MAIN_FILE, + manifest_data, + ) + + +def _plugin_config_dir(plugin_dir: Path) -> Path: + if plugin_dir.parent.name == "plugins" and plugin_dir.parent.parent.exists(): + return plugin_dir.parent.parent / "config" + return plugin_dir / "data" / "config" + + +def _plugin_config_path(plugin_dir: Path, plugin_name: str) -> Path: + return _plugin_config_dir(plugin_dir) / f"{plugin_name}_config.json" + + +def _schema_default(field_schema: dict[str, Any]) -> Any: + if "default" in field_schema: + return copy.deepcopy(field_schema["default"]) + + field_type = str(field_schema.get("type") or "string") + if field_type == "object": + items = field_schema.get("items") + if isinstance(items, dict): + return { + key: _normalize_config_value(child_schema, None) + for key, child_schema in items.items() + if isinstance(child_schema, dict) + } + return {} + if field_type in {"list", "template_list", "file"}: + return [] + if field_type == "dict": + return {} + if field_type == "int": + return 0 + if field_type == "float": + return 0.0 + if field_type == "bool": + return False + return "" + + +def _normalize_config_value(field_schema: dict[str, Any], value: Any) -> Any: + field_type = str(field_schema.get("type") or "string") + default_value = _schema_default(field_schema) + + if field_type == "object": + items = field_schema.get("items") + if not isinstance(items, dict): + return default_value + current = value if isinstance(value, dict) else {} + return { + key: _normalize_config_value(child_schema, current.get(key)) + for key, child_schema in items.items() + if isinstance(child_schema, dict) + } + if field_type in {"list", "template_list", "file"}: + return copy.deepcopy(value) if isinstance(value, list) else default_value + if field_type == "dict": + return copy.deepcopy(value) if isinstance(value, dict) else default_value + if field_type == "int": + return value if isinstance(value, int) and not isinstance(value, bool) else default_value + if field_type == "float": + return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default_value + if field_type == "bool": + return value if isinstance(value, bool) else default_value + if field_type in {"string", "text"}: + return value if isinstance(value, str) else default_value + return copy.deepcopy(value) if value is not None else default_value + + +def _load_plugin_config(plugin: PluginSpec) -> AstrBotConfig | None: + schema_path = plugin.plugin_dir / CONFIG_SCHEMA_FILE + if not schema_path.exists(): + return None + + try: + schema_payload = json.loads(schema_path.read_text(encoding="utf-8")) + except Exception: + schema_payload = {} + schema = schema_payload if isinstance(schema_payload, dict) else {} + + config_path = _plugin_config_path(plugin.plugin_dir, plugin.name) + try: + existing_payload = ( + json.loads(config_path.read_text(encoding="utf-8")) + if config_path.exists() + else {} + ) + except Exception: + existing_payload = {} + existing = existing_payload if isinstance(existing_payload, dict) else {} + normalized = { + key: _normalize_config_value(field_schema, existing.get(key)) + for key, field_schema in schema.items() + if isinstance(field_schema, dict) + } + config = AstrBotConfig(normalized, save_path=config_path) + if not config_path.exists() or normalized != existing: + config.save_config() + return config + + +def _legacy_component_classes(plugin: PluginSpec) -> list[type[Any]]: + module_name = f"_astrbot_legacy_{plugin.name}_main" + module_path = plugin.plugin_dir / LEGACY_MAIN_FILE + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + return [] + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + component_classes: list[type[Any]] = [] + for _, candidate in inspect.getmembers(module, inspect.isclass): + if candidate.__module__ != module.__name__: + continue + if not issubclass(candidate, Star) or candidate is Star: + continue + component_classes.append(candidate) + + component_classes.sort(key=lambda cls: cls.__name__) + return component_classes + + +def _plugin_component_classes(plugin: PluginSpec) -> list[type[Any]]: + component_classes: list[type[Any]] = [] + for component in plugin.manifest_data.get("components", []): + class_path = component.get("class") + if not isinstance(class_path, str) or ":" not in class_path: + continue + component_classes.append(import_string(class_path)) + + if component_classes: + return component_classes + if plugin.manifest_data.get(LEGACY_MAIN_MANIFEST_KEY): + return _legacy_component_classes(plugin) + return [] + + +def _select_legacy_constructor_args( + component_cls: type[Any], + legacy_context: Any, + config: AstrBotConfig | None, +) -> tuple[Any, ...]: + try: + signature = inspect.signature(component_cls) + except (TypeError, ValueError): + return (legacy_context, config) if config is not None else (legacy_context,) + + positional_params = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ] + has_varargs = any( + parameter.kind == inspect.Parameter.VAR_POSITIONAL + for parameter in signature.parameters.values() + ) + max_args = None if has_varargs else len(positional_params) + + if config is not None and (max_args is None or max_args >= 2): + return (legacy_context, config) + if max_args is None or max_args >= 1: + return (legacy_context,) + return () + + def load_plugin_spec(plugin_dir: Path) -> PluginSpec: plugin_dir = plugin_dir.resolve() - manifest_path = plugin_dir / "plugin.yaml" + manifest_path = plugin_dir / PLUGIN_MANIFEST_FILE requirements_path = plugin_dir / "requirements.txt" - manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + if manifest_path.exists(): + manifest_data = _read_yaml(manifest_path) + else: + manifest_path, manifest_data = _build_legacy_manifest(plugin_dir) runtime = manifest_data.get("runtime") or {} - python_version = ( - runtime.get("python") or f"{sys.version_info.major}.{sys.version_info.minor}" - ) + python_version = runtime.get("python") or _default_python_version() return PluginSpec( name=str(manifest_data.get("name") or plugin_dir.name), plugin_dir=plugin_dir, @@ -217,19 +436,20 @@ def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult: for entry in sorted(plugins_root.iterdir()): if not entry.is_dir() or entry.name.startswith("."): continue - manifest_path = entry / "plugin.yaml" + manifest_path = entry / PLUGIN_MANIFEST_FILE requirements_path = entry / "requirements.txt" - if not manifest_path.exists(): + if not manifest_path.exists() and not _looks_like_legacy_plugin(entry): continue - if not requirements_path.exists(): + if manifest_path.exists() and not requirements_path.exists(): skipped_plugins[entry.name] = "missing requirements.txt" continue try: - manifest_data = ( - yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} - ) + if manifest_path.exists(): + manifest_data = _read_yaml(manifest_path) + else: + manifest_path, manifest_data = _build_legacy_manifest(entry) except Exception as exc: - skipped_plugins[entry.name] = f"failed to parse plugin.yaml: {exc}" + skipped_plugins[entry.name] = f"failed to parse plugin manifest: {exc}" continue plugin_name = manifest_data.get("name") runtime = manifest_data.get("runtime") or {} @@ -301,7 +521,7 @@ class PluginEnvironmentManager: cwd=self.repo_root, command_name=f"create venv for {plugin.name}", ) - requirements_text = plugin.requirements_path.read_text(encoding="utf-8").strip() + requirements_text = _read_requirements_text(plugin.requirements_path).strip() if not requirements_text: return self._run_command( @@ -341,7 +561,7 @@ class PluginEnvironmentManager: @staticmethod def _fingerprint(plugin: PluginSpec) -> str: - requirements = plugin.requirements_path.read_text(encoding="utf-8") + requirements = _read_requirements_text(plugin.requirements_path) payload = { "python_version": plugin.python_version, "requirements": requirements, @@ -392,11 +612,8 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin: instances: list[Any] = [] handlers: list[LoadedHandler] = [] shared_legacy_context = None - for component in plugin.manifest_data.get("components", []): - class_path = component.get("class") - if not isinstance(class_path, str) or ":" not in class_path: - continue - component_cls = import_string(class_path) + plugin_config = _load_plugin_config(plugin) + for component_cls in _plugin_component_classes(plugin): legacy_context = None if _is_new_star_component(component_cls): instance = component_cls() @@ -407,12 +624,14 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin: component_cls, plugin.name ) legacy_context = shared_legacy_context - try: - instance = component_cls(legacy_context) - except TypeError: - instance = component_cls() - if getattr(instance, "context", None) is None: - setattr(instance, "context", legacy_context) + constructor_args = _select_legacy_constructor_args( + component_cls, legacy_context, plugin_config + ) + instance = component_cls(*constructor_args) + if getattr(instance, "context", None) is None: + setattr(instance, "context", legacy_context) + if plugin_config is not None and getattr(instance, "config", None) is None: + setattr(instance, "config", plugin_config) instances.append(instance) for name in _iter_handler_names(instance): resolved = _resolve_handler_candidate(instance, name) diff --git a/tests_v4/test_api_legacy_context.py b/tests_v4/test_api_legacy_context.py index 449dacf82..63083ca5f 100644 --- a/tests_v4/test_api_legacy_context.py +++ b/tests_v4/test_api_legacy_context.py @@ -15,6 +15,7 @@ from astrbot_sdk._legacy_api import ( LegacyContext, LegacyConversationManager, ) +from astrbot_sdk.api.message import Comp, MessageChain from astrbot_sdk.star import Star @@ -302,6 +303,34 @@ class TestLegacyContextSendMessage: mock_platform.send.assert_called_once_with("session-1", "extracted text") + @pytest.mark.asyncio + async def test_send_message_with_message_chain_uses_send_chain(self): + """send_message() should preserve rich chains when MessageChain is available.""" + mock_platform = AsyncMock() + + mock_ctx = MagicMock() + mock_ctx.platform = mock_platform + + legacy_ctx = LegacyContext("test_plugin") + legacy_ctx._runtime_context = mock_ctx + + chain = MessageChain( + [ + Comp.Plain(text="hello"), + Comp.Image(file="https://example.com/image.png"), + ] + ) + + await legacy_ctx.send_message("session-1", chain) + + mock_platform.send_chain.assert_called_once_with( + "session-1", + [ + {"type": "Plain", "text": "hello"}, + {"type": "Image", "file": "https://example.com/image.png"}, + ], + ) + @pytest.mark.asyncio async def test_send_message_with_to_text_method(self): """send_message() should use to_text() if get_plain_text() not available.""" diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py index 8d56bf4b5..3585f7b11 100644 --- a/tests_v4/test_api_modules.py +++ b/tests_v4/test_api_modules.py @@ -4,6 +4,8 @@ Tests for API module exports and re-exports. from __future__ import annotations +import pytest + class TestApiStarModule: """Tests for api/star module exports.""" @@ -29,6 +31,22 @@ class TestApiStarModule: assert metadata.name == "demo" assert metadata.version == "1.0.0" + def test_star_module_exports_legacy_star_and_register(self): + """api.star should expose legacy Star/register imports.""" + from astrbot_sdk._legacy_api import LegacyStar + from astrbot_sdk.api.star import Star, register + + @register(name="demo", author="tester") + class DemoStar(Star): + pass + + assert Star is LegacyStar + assert callable(register) + assert DemoStar.__astrbot_plugin_metadata__ == { + "name": "demo", + "author": "tester", + } + class TestApiComponentsModule: """Tests for api/components module exports.""" @@ -85,6 +103,50 @@ class TestApiEventModule: assert MessageSession is not None assert MessageType is not None + def test_message_chain_serializes_components(self): + """MessageChain.to_payload() should preserve compat component fields.""" + from astrbot_sdk.api.message import Comp, MessageChain + + chain = MessageChain( + [ + Comp.Plain(text="hello"), + Comp.Image(file="https://example.com/image.png"), + ] + ) + + assert chain.to_payload() == [ + {"type": "Plain", "text": "hello"}, + {"type": "Image", "file": "https://example.com/image.png"}, + ] + + @pytest.mark.asyncio + async def test_astr_message_event_send_uses_send_chain_when_context_bound(self): + """AstrMessageEvent.send() should use platform.send_chain for rich messages.""" + from unittest.mock import AsyncMock, MagicMock + + from astrbot_sdk.api.event import AstrMessageEvent + from astrbot_sdk.api.message import Comp, MessageChain + + runtime_context = MagicMock() + runtime_context.platform = AsyncMock() + event = AstrMessageEvent(session_id="session-1", context=runtime_context) + chain = MessageChain( + [ + Comp.Plain(text="hello"), + Comp.Image(file="https://example.com/image.png"), + ] + ) + + await event.send(chain) + + runtime_context.platform.send_chain.assert_called_once_with( + "session-1", + [ + {"type": "Plain", "text": "hello"}, + {"type": "Image", "file": "https://example.com/image.png"}, + ], + ) + class TestApiModule: """Tests for top-level api module.""" @@ -97,9 +159,16 @@ class TestApiModule: def test_api_subpackages_exist(self): """New compat subpackages should be importable.""" - from astrbot_sdk.api import basic, message, platform, provider + from astrbot_sdk.api import ( + basic, + message, + message_components, + platform, + provider, + ) assert basic is not None assert message is not None + assert message_components is not None assert platform is not None assert provider is not None diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py index 70ecfc0fd..4c8dddfd7 100644 --- a/tests_v4/test_capability_router.py +++ b/tests_v4/test_capability_router.py @@ -168,6 +168,7 @@ class TestCapabilityRouterInit: # Platform capabilities assert "platform.send" in capability_names assert "platform.send_image" in capability_names + assert "platform.send_chain" in capability_names assert "platform.get_members" in capability_names def test_builtin_descriptors_use_protocol_schema_registry(self): @@ -813,6 +814,34 @@ class TestBuiltinPlatformCapabilities: assert len(router.sent_messages) == 1 assert router.sent_messages[0]["image_url"] == "http://example.com/image.png" + @pytest.mark.asyncio + async def test_platform_send_chain(self): + """platform.send_chain should store rich message payloads.""" + router = CapabilityRouter() + token = CancelToken() + + result = await router.execute( + "platform.send_chain", + { + "session": "session-1", + "chain": [ + {"type": "Plain", "text": "Hello"}, + {"type": "Image", "file": "http://example.com/image.png"}, + ], + }, + stream=False, + cancel_token=token, + request_id="req-1", + ) + + assert "message_id" in result + assert len(router.sent_messages) == 1 + assert router.sent_messages[0]["chain"][0]["text"] == "Hello" + assert ( + router.sent_messages[0]["chain"][1]["file"] + == "http://example.com/image.png" + ) + @pytest.mark.asyncio async def test_platform_get_members(self): """platform.get_members should return mock members.""" diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py index 8bcc45ffc..71b3f9c5a 100644 --- a/tests_v4/test_handler_dispatcher.py +++ b/tests_v4/test_handler_dispatcher.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from astrbot_sdk.api.event import AstrMessageEvent +from astrbot_sdk.api.message import Comp, MessageChain from astrbot_sdk.context import CancelToken, Context from astrbot_sdk.events import MessageEvent, PlainTextResult from astrbot_sdk.protocol.descriptors import ( @@ -45,6 +46,14 @@ class MockPeer: payload.get("text", ""), ) return {} + if name == "platform.send_chain": + self.sent_messages.append( + { + "session_id": payload.get("session", ""), + "chain": payload.get("chain", []), + } + ) + return {} return {} @@ -670,6 +679,37 @@ class TestHandlerDispatcherConsumeResult: await dispatcher._consume_legacy_result(123, event) await dispatcher._consume_legacy_result(None, event) + @pytest.mark.asyncio + async def test_consume_message_chain_uses_platform_send_chain(self): + """_consume_legacy_result should preserve rich chains when ctx is available.""" + peer = MockPeer() + dispatcher = HandlerDispatcher( + plugin_id="test_plugin", + peer=peer, + handlers=[], + ) + + event = create_message_event() + ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken()) + chain = MessageChain( + [ + Comp.Plain(text="hello"), + Comp.Image(file="https://example.com/image.png"), + ] + ) + + await dispatcher._consume_legacy_result(chain, event, ctx) + + assert peer.sent_messages == [ + { + "session_id": "session-1", + "chain": [ + {"type": "Plain", "text": "hello"}, + {"type": "Image", "file": "https://example.com/image.png"}, + ], + } + ] + class TestHandlerDispatcherHandleError: """Tests for HandlerDispatcher._handle_error method.""" diff --git a/tests_v4/test_loader.py b/tests_v4/test_loader.py index 1e4c4cbd5..05b87bb62 100644 --- a/tests_v4/test_loader.py +++ b/tests_v4/test_loader.py @@ -4,6 +4,7 @@ Tests for runtime/loader.py - Plugin loading utilities. from __future__ import annotations +import json import sys import tempfile import textwrap @@ -496,6 +497,26 @@ class TestDiscoverPlugins: assert len(result.plugins) == 1 assert result.plugins[0].name == "valid_plugin" + def test_discovers_legacy_main_plugin_without_manifest(self): + """discover_plugins should accept legacy plugins with main.py.""" + with tempfile.TemporaryDirectory() as temp_dir: + plugins_dir = Path(temp_dir) + plugin_dir = plugins_dir / "legacy_plugin" + plugin_dir.mkdir() + (plugin_dir / "main.py").write_text( + "from astrbot_sdk.api.star import Star\n\nclass LegacyPlugin(Star):\n pass\n", + encoding="utf-8", + ) + (plugin_dir / "metadata.yaml").write_text( + yaml.dump({"name": "legacy_plugin", "author": "tester"}), + encoding="utf-8", + ) + + result = discover_plugins(plugins_dir) + + assert [plugin.name for plugin in result.plugins] == ["legacy_plugin"] + assert result.skipped_plugins == {} + class TestPluginEnvironmentManager: """Tests for PluginEnvironmentManager class.""" @@ -801,6 +822,71 @@ class TestLoadPlugin: if str(plugin_dir) in sys.path: sys.path.remove(str(plugin_dir)) + def test_load_plugin_supports_legacy_main_and_config_schema(self): + """load_plugin should auto-discover main.py legacy stars and inject config.""" + with tempfile.TemporaryDirectory() as temp_dir: + plugin_dir = Path(temp_dir) / "legacy_plugin" + plugin_dir.mkdir() + (plugin_dir / "main.py").write_text( + textwrap.dedent( + """\ + from astrbot_sdk.api.event import AstrMessageEvent, filter + from astrbot_sdk.api.star import Context, Star + + + class LegacyPlugin(Star): + def __init__(self, context: Context, config): + super().__init__(context, config) + + @filter.command("hello") + async def hello(self, event: AstrMessageEvent): + yield event.plain_result(self.config["token"]) + """ + ), + encoding="utf-8", + ) + (plugin_dir / "metadata.yaml").write_text( + yaml.dump({"name": "legacy_plugin", "version": "1.0.0"}), + encoding="utf-8", + ) + (plugin_dir / "_conf_schema.json").write_text( + json.dumps( + { + "token": { + "type": "string", + "default": "demo-token", + }, + "nested": { + "type": "object", + "items": { + "enabled": { + "type": "bool", + "default": True, + } + }, + }, + } + ), + encoding="utf-8", + ) + + spec = load_plugin_spec(plugin_dir) + loaded = load_plugin(spec) + + assert len(loaded.instances) == 1 + instance = loaded.instances[0] + assert instance.context.plugin_id == "legacy_plugin" + assert instance.config["token"] == "demo-token" + assert instance.config["nested"] == {"enabled": True} + + config_path = plugin_dir / "data" / "config" / "legacy_plugin_config.json" + assert config_path.exists() + + instance.config["token"] = "changed" + instance.config.save_config() + persisted = json.loads(config_path.read_text(encoding="utf-8")) + assert persisted["token"] == "changed" + class TestStateFileConstant: """Tests for STATE_FILE_NAME constant.""" diff --git a/tests_v4/test_platform_client.py b/tests_v4/test_platform_client.py index b072fc17c..a57c77849 100644 --- a/tests_v4/test_platform_client.py +++ b/tests_v4/test_platform_client.py @@ -108,6 +108,47 @@ class TestPlatformClientSendImage: assert call_args["image_url"] == "data:image/png;base64,abc123" +class TestPlatformClientSendChain: + """Tests for PlatformClient.send_chain() method.""" + + @pytest.mark.asyncio + async def test_send_chain_returns_response(self): + """send_chain() should return response dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"message_id": "chain_123"}) + + client = PlatformClient(proxy) + result = await client.send_chain( + "session-1", + [{"type": "Plain", "text": "Hello"}], + ) + + proxy.call.assert_called_once_with( + "platform.send_chain", + {"session": "session-1", "chain": [{"type": "Plain", "text": "Hello"}]}, + ) + assert result["message_id"] == "chain_123" + + @pytest.mark.asyncio + async def test_send_chain_with_multiple_components(self): + """send_chain() should preserve the original component payloads.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + await client.send_chain( + "session-1", + [ + {"type": "Plain", "text": "Hello"}, + {"type": "Image", "file": "https://example.com/a.png"}, + ], + ) + + call_args = proxy.call.call_args[0][1] + assert call_args["chain"][0]["text"] == "Hello" + assert call_args["chain"][1]["file"] == "https://example.com/a.png" + + class TestPlatformClientGetMembers: """Tests for PlatformClient.get_members() method."""