mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
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:
@@ -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,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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]]:
|
||||
"""获取群组成员列表。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user