feat: Enhance legacy API with message chain support and new context handling

- Updated `LegacyContext.send_message` to handle rich message chains using `send_chain`.
- Introduced `LegacyStar` class for backward compatibility with legacy plugins.
- Added `register` decorator for legacy plugin metadata.
- Enhanced `MessageChain` class with `to_payload` and `is_plain_text_only` methods.
- Updated `AstrMessageEvent.send` method to utilize `send_chain` for rich messages.
- Implemented `send_chain` method in `PlatformClient` for sending complex message structures.
- Added capability routing for `platform.send_chain`.
- Introduced tests for new functionality, ensuring compatibility with legacy plugins and message handling.
This commit is contained in:
whatevertogo
2026-03-13 05:34:14 +08:00
parent ae73a18f60
commit 96ea836edc
22 changed files with 850 additions and 55 deletions
+1 -9
View File
@@ -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.
# 开发命令
+1 -9
View File
@@ -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.
# 开发命令
+68 -3
View File
@@ -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",
]
+11 -1
View File
@@ -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",
@@ -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
@@ -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:
@@ -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",
+33
View File
@@ -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
@@ -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",
]
+2 -1
View File
@@ -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"]
+21 -1
View File
@@ -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]]:
"""获取群组成员列表。
+1
View File
@@ -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(
@@ -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",
@@ -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,
@@ -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)
+244 -25
View File
@@ -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)
+29
View File
@@ -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."""
+70 -1
View File
@@ -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
+29
View File
@@ -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."""
+40
View File
@@ -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."""
+86
View File
@@ -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."""
+41
View File
@@ -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."""