feat: 更新协议模块,增强 v4 协议适配器和消息解析功能,添加新测试用例

This commit is contained in:
whatevertogo
2026-03-13 04:15:43 +08:00
parent 2e990f81e0
commit 8ebf5489b6
9 changed files with 271 additions and 175 deletions

View File

@@ -11,6 +11,7 @@
- 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.
# 开发命令

View File

@@ -11,6 +11,7 @@
- 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.
# 开发命令

View File

@@ -1,76 +1,101 @@
"""协议模块
"""AstrBot v4 协议公共入口
定义 AstrBot SDK 的消息协议和描述符,用于插件与核心之间的通信。
所有消息均使用 Pydantic 定义,确保类型安全和序列化一致性。
这里暴露的是协议层的公共模型和 legacy 适配入口。需要区分两件事:
架构说明:
旧版:
- 使用标准 JSON-RPC 2.0 协议
- 消息类型较少: Request, SuccessResponse, ErrorResponse
- 特定请求类型绑定了 AstrMessageEventModel 事件模型
- 使用 dataclass 和 pydantic 混合定义
1. v4 原生协议:
`InitializeMessage` / `InvokeMessage` / `ResultMessage` / `EventMessage`
2. legacy JSON-RPC 兼容:
`LegacyAdapter` 及其若干便捷转换函数
新版:
- 全新的自描述协议,使用 `type` 字段区分消息类型
- 更丰富的消息类型: Initialize, Result, Invoke, Event, Cancel
- 强大的描述符系统: HandlerDescriptor, CapabilityDescriptor
- 多种触发器类型: Command, Message, Event, Schedule
- 纯 Pydantic 定义,支持严格验证
- 提供 LegacyAdapter 实现新旧协议互操作
协议消息流程:
1. Initialize: 握手建立连接,交换能力和处理器信息
2. Invoke: 调用远程能力
3. Event: 流式事件通知 (started/delta/completed/failed)
4. Result: 调用结果返回
5. Cancel: 取消正在进行的调用
与旧版对比:
旧版 JSON-RPC 消息:
{
"jsonrpc": "2.0",
"id": "xxx",
"method": "call_handler",
"params": {"handler_full_name": "...", "event": {...}}
}
新版协议消息:
{
"type": "invoke",
"id": "xxx",
"capability": "handler.invoke",
"input": {"handler_id": "...", "event": {...}}
}
TODO: (功能完善):
- 添加消息签名验证支持,确保消息来源可信
- 添加消息压缩支持,减少大数据传输开销
- 添加批量消息支持 (BatchMessage),提高传输效率
- 添加消息追踪 ID (trace_id) 支持,便于日志关联
- CapabilityDescriptor 缺少 rate_limit 限流配置
- HandlerDescriptor 缺少 timeout 超时配置
- 缺少心跳消息 (HeartbeatMessage) 支持
- 缺少健康检查消息 (HealthCheckMessage) 支持
握手阶段由 `InitializeMessage` 发起,返回值不是另一条 initialize 消息,而是
`ResultMessage(kind="initialize_result")`,其 `output` 负载可解析为
`InitializeOutput`。
"""
from .descriptors import CapabilityDescriptor, HandlerDescriptor, Permissions
from .descriptors import (
CapabilityDescriptor,
CommandTrigger,
EventTrigger,
HandlerDescriptor,
MessageTrigger,
Permissions,
ScheduleTrigger,
Trigger,
)
from .legacy_adapter import (
LEGACY_ADAPTER_MESSAGE_EVENT,
LEGACY_CONTEXT_CAPABILITY,
LEGACY_HANDSHAKE_METADATA_KEY,
LEGACY_JSONRPC_VERSION,
LEGACY_PLUGIN_KEYS_METADATA_KEY,
LegacyAdapter,
LegacyErrorData,
LegacyErrorResponse,
LegacyMessage,
LegacyRequest,
LegacySuccessResponse,
LegacyToV4Message,
cancel_to_legacy_request,
event_to_legacy_notification,
initialize_to_legacy_handshake_response,
invoke_to_legacy_request,
legacy_message_to_v4,
legacy_request_to_invoke,
legacy_response_to_message,
parse_legacy_message,
result_to_legacy_response,
)
from .messages import (
CancelMessage,
ErrorPayload,
EventMessage,
InitializeMessage,
InitializeOutput,
InvokeMessage,
PeerInfo,
ProtocolMessage,
ResultMessage,
parse_message,
)
__all__ = [
"CapabilityDescriptor",
"CommandTrigger",
"CancelMessage",
"ErrorPayload",
"EventTrigger",
"EventMessage",
"HandlerDescriptor",
"InitializeMessage",
"InitializeOutput",
"InvokeMessage",
"LEGACY_ADAPTER_MESSAGE_EVENT",
"LEGACY_CONTEXT_CAPABILITY",
"LEGACY_HANDSHAKE_METADATA_KEY",
"LEGACY_JSONRPC_VERSION",
"LEGACY_PLUGIN_KEYS_METADATA_KEY",
"LegacyAdapter",
"LegacyErrorData",
"LegacyErrorResponse",
"LegacyMessage",
"LegacyRequest",
"LegacySuccessResponse",
"LegacyToV4Message",
"MessageTrigger",
"PeerInfo",
"Permissions",
"ProtocolMessage",
"ResultMessage",
"ScheduleTrigger",
"Trigger",
"cancel_to_legacy_request",
"event_to_legacy_notification",
"initialize_to_legacy_handshake_response",
"invoke_to_legacy_request",
"legacy_message_to_v4",
"legacy_request_to_invoke",
"legacy_response_to_message",
"parse_legacy_message",
"parse_message",
"result_to_legacy_response",
]

View File

@@ -1,39 +1,8 @@
"""描述符模
"""v4 协议描述符模
定义处理器和能力的描述符,用于声明式注册和发现。
描述符类型概览:
HandlerDescriptor: 处理器描述符,描述一个事件处理函数
CapabilityDescriptor: 能力描述符,描述一个可调用的远程能力
Permissions: 权限配置,控制处理器的访问权限
Trigger: 触发器联合类型,支持多种触发方式
触发器类型:
CommandTrigger: 命令触发器,响应特定命令(如 /help
MessageTrigger: 消息触发器,响应匹配正则或关键词的消息
EventTrigger: 事件触发器,响应特定类型的事件
ScheduleTrigger: 定时触发器,按 cron 或间隔时间执行
与旧版对比:
旧版:
- 处理器元信息分散在 handshake 响应中
- 使用 event_type 整数区分事件类型
- 缺少声明式的触发器定义
- 配置通过 extras_configs 字典传递
新版:
- 使用 HandlerDescriptor 统一描述处理器
- 使用字符串 event_type更语义化
- 支持多种触发器类型,声明式定义
- 使用 Pydantic 模型,类型安全
TODO:
- HandlerDescriptor 缺少 timeout 超时配置
- HandlerDescriptor 缺少 retry 重试配置
- CapabilityDescriptor 缺少 rate_limit 限流配置
- ScheduleTrigger 缺错时错过执行的处理策略
- 缺少 HandlerGroupDescriptor 处理器组描述符
- 缺少 DependencyDescriptor 依赖声明
`protocol` 是 v4 新引入的协议层抽象,不对应旧树中的一个同名目录。这里
定义的是跨进程握手和调度时使用的声明式元数据,而不是运行时的具体处理器/
能力实现。
"""
from __future__ import annotations
@@ -85,7 +54,7 @@ class CommandTrigger(_DescriptorBase):
class MessageTrigger(_DescriptorBase):
"""消息触发器,响应匹配正则或关键词的消息
"""消息触发器,描述消息类处理器的订阅条件
与旧版对比:
旧版: 使用 @regex_handler(r"pattern") 或 @message_handler 装饰器
@@ -96,6 +65,10 @@ class MessageTrigger(_DescriptorBase):
regex: 正则表达式模式,匹配消息文本
keywords: 关键词列表,消息包含任一关键词即触发
platforms: 目标平台列表,为空表示所有平台
Note:
`regex` 和 `keywords` 可以同时为空,此时表示“任意消息均可触发”,
仅由平台过滤或上层运行时进一步筛选。
"""
type: Literal["message"] = "message"
@@ -224,3 +197,15 @@ class CapabilityDescriptor(_DescriptorBase):
output_schema: dict[str, Any] | None = None
supports_stream: bool = False
cancelable: bool = False
__all__ = [
"CapabilityDescriptor",
"CommandTrigger",
"EventTrigger",
"HandlerDescriptor",
"MessageTrigger",
"Permissions",
"ScheduleTrigger",
"Trigger",
]

View File

@@ -1,35 +1,8 @@
"""旧版协议适配器模块
"""legacy JSON-RPC 与 v4 协议之间的适配器。
提供旧版 JSON-RPC 协议与新版协议之间的双向转换。
支持旧版插件与新版核心的互操作。
主要功能:
- 将旧版 JSON-RPC 请求转换为新版 InvokeMessage
- 将旧版 JSON-RPC 响应转换为新版 ResultMessage
- 将旧版 handshake 转换为新版 InitializeMessage
- 将新版消息转换回旧版格式(用于与旧版核心通信)
转换映射表:
旧版 method -> 新版 capability
------------------------------------------------
handshake -> InitializeMessage
call_handler -> handler.invoke
call_context_function -> internal.legacy.call_context_function
handler_stream_start -> EventMessage(phase="started")
handler_stream_update -> EventMessage(phase="delta")
handler_stream_end -> EventMessage(phase="completed"/"failed")
cancel -> CancelMessage
注意事项:
- 旧版 handshake 的 metadata 信息可能丢失部分字段
- 新版触发器的详细信息在转换时可能丢失
- 使用 LEGACY_HANDSHAKE_METADATA_KEY 保留原始握手数据
TODO:
- 添加旧版消息版本检测和兼容性警告
- 添加消息转换日志记录,便于调试
- 支持自定义转换规则扩展
- 添加转换性能监控
旧树没有独立的 `protocol` 包;这里做的是“旧 JSON-RPC 运行时语义”到
“v4 协议模型”的转换。它不是完美双向同构,尤其是 legacy handshake 无法
保留 v4 触发器的全部细节,因此适配器会保留原始握手载荷供兼容层回退使用。
"""
from __future__ import annotations
@@ -627,6 +600,8 @@ def parse_legacy_message(
payload = payload.decode("utf-8")
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict):
raise ValueError("legacy JSON-RPC 消息必须是 JSON object")
if "method" in payload:
return LegacyRequest.model_validate(payload)
if "result" in payload:
@@ -642,7 +617,9 @@ def legacy_message_to_v4(
return LegacyAdapter().legacy_to_v4(payload)
def legacy_request_to_invoke(payload: dict[str, Any]) -> InvokeMessage:
def legacy_request_to_invoke(
payload: str | bytes | dict[str, Any] | LegacyRequest,
) -> InvokeMessage:
message = LegacyAdapter().legacy_request_to_message(payload)
if not isinstance(message, InvokeMessage):
raise ValueError("legacy request 不能直接映射为 invoke")
@@ -650,7 +627,7 @@ def legacy_request_to_invoke(payload: dict[str, Any]) -> InvokeMessage:
def legacy_response_to_message(
payload: dict[str, Any],
payload: str | bytes | dict[str, Any] | LegacySuccessResponse,
) -> InitializeMessage | ResultMessage:
message = LegacyAdapter().legacy_response_to_message(payload)
return message
@@ -695,11 +672,14 @@ __all__ = [
"LEGACY_CONTEXT_CAPABILITY",
"LEGACY_HANDSHAKE_METADATA_KEY",
"LEGACY_PLUGIN_KEYS_METADATA_KEY",
"LEGACY_JSONRPC_VERSION",
"LegacyAdapter",
"LegacyErrorData",
"LegacyErrorResponse",
"LegacyMessage",
"LegacyRequest",
"LegacySuccessResponse",
"LegacyToV4Message",
"cancel_to_legacy_request",
"event_to_legacy_notification",
"initialize_to_legacy_handshake_response",

View File

@@ -1,47 +1,9 @@
"""协议消息定义模块
"""v4 协议消息模型
定义 AstrBot SDK 的核心消息类型,所有消息均继承自 Pydantic BaseModel。
消息类型概览:
InitializeMessage: 握手初始化消息,包含 Peer 信息和处理器列表
ResultMessage: 调用结果消息,包含成功/失败状态和输出数据
InvokeMessage: 能力调用消息,指定目标能力和输入参数
EventMessage: 流式事件消息,用于流式调用的状态通知
CancelMessage: 取消消息,用于取消正在进行的调用
消息生命周期:
握手阶段:
Plugin -> Core: InitializeMessage (注册处理器)
Core -> Plugin: ResultMessage (确认或拒绝)
调用阶段:
Plugin -> Core: InvokeMessage (调用能力)
Core -> Plugin: ResultMessage (返回结果)
或者 (流式):
Core -> Plugin: EventMessage (started -> delta* -> completed/failed)
取消阶段:
Plugin -> Core: CancelMessage (取消调用)
与旧版对比:
旧版 JSON-RPC:
- 使用 method 字段区分操作类型
- 使用 jsonrpc: "2.0" 标识协议版本
- 错误码为整数 (如 -32000)
- 无专门的取消消息类型
新版协议:
- 使用 type 字段区分消息类型
- 使用 protocol_version 字段标识版本
- 错误码为字符串 (如 "internal_error")
- 有专门的 CancelMessage 取消消息
TODO:
- 添加消息过期时间 (expires_at) 支持
- 添加消息优先级 (priority) 支持
- 添加消息重试计数 (retry_count) 支持
- ErrorPayload 缺少 stack_trace 字段(调试用)
- InitializeMessage 缺少 authentication 认证字段
这些模型描述的是 `Peer` 与 `Peer` 之间的线协议。握手阶段通过
`InitializeMessage` 发起,再由 `ResultMessage(kind="initialize_result")`
返回 `InitializeOutput`;能力调用阶段则使用 `InvokeMessage` / `ResultMessage`
或 `EventMessage` 序列。
"""
from __future__ import annotations
@@ -184,6 +146,19 @@ class ResultMessage(_MessageBase):
output: dict[str, Any] = Field(default_factory=dict)
error: ErrorPayload | None = None
@model_validator(mode="after")
def validate_result_state(self) -> "ResultMessage":
"""约束 success / output / error 的组合状态。"""
if self.success:
if self.error is not None:
raise ValueError("success=true 时 error 必须为空")
return self
if self.error is None:
raise ValueError("success=false 时必须提供 error")
if self.output:
raise ValueError("success=false 时 output 必须为空")
return self
class InvokeMessage(_MessageBase):
"""调用消息,用于请求执行远程能力。
@@ -309,15 +284,25 @@ ProtocolMessage = (
)
"""协议消息联合类型,所有有效消息类型的联合。"""
_PROTOCOL_MESSAGE_MODELS = {
"initialize": InitializeMessage,
"result": ResultMessage,
"invoke": InvokeMessage,
"event": EventMessage,
"cancel": CancelMessage,
}
def parse_message(payload: str | bytes | dict[str, Any]) -> ProtocolMessage:
def parse_message(
payload: ProtocolMessage | str | bytes | dict[str, Any],
) -> ProtocolMessage:
"""解析协议消息。
从原始载荷(字符串、字节或字典)解析为对应的 ProtocolMessage 类型。
根据 "type" 字段自动识别消息类型并验证。
Args:
payload: 原始消息载荷,支持 JSON 字符串、字节或字典
payload: 原始消息载荷,支持已解析模型、JSON 字符串、字节或字典
Returns:
解析后的协议消息对象
@@ -330,19 +315,39 @@ def parse_message(payload: str | bytes | dict[str, Any]) -> ProtocolMessage:
>>> isinstance(msg, InvokeMessage)
True
"""
if isinstance(
payload,
(
InitializeMessage,
ResultMessage,
InvokeMessage,
EventMessage,
CancelMessage,
),
):
return payload
if isinstance(payload, bytes):
payload = payload.decode("utf-8")
if isinstance(payload, str):
payload = json.loads(payload)
if not isinstance(payload, dict):
raise ValueError("协议消息必须是 JSON object")
message_type = payload.get("type")
if message_type == "initialize":
return InitializeMessage.model_validate(payload)
if message_type == "result":
return ResultMessage.model_validate(payload)
if message_type == "invoke":
return InvokeMessage.model_validate(payload)
if message_type == "event":
return EventMessage.model_validate(payload)
if message_type == "cancel":
return CancelMessage.model_validate(payload)
model = _PROTOCOL_MESSAGE_MODELS.get(str(message_type))
if model is not None:
return model.model_validate(payload)
raise ValueError(f"未知消息类型:{message_type}")
__all__ = [
"CancelMessage",
"ErrorPayload",
"EventMessage",
"InitializeMessage",
"InitializeOutput",
"InvokeMessage",
"PeerInfo",
"ProtocolMessage",
"ResultMessage",
"parse_message",
]

View File

@@ -158,6 +158,11 @@ class TestParseLegacyMessage:
parse_legacy_message({"jsonrpc": "2.0", "unknown": "field"})
assert "未知" in str(exc_info.value)
def test_parse_non_mapping_raises(self):
"""parse_legacy_message should reject non-object payloads."""
with pytest.raises(ValueError, match="JSON object"):
parse_legacy_message(["not", "an", "object"])
def test_pass_through_legacy_message(self):
"""parse_legacy_message should pass through already-parsed messages."""
req = LegacyRequest(method="test")

View File

@@ -237,6 +237,33 @@ class TestResultMessage:
msg = ResultMessage(id="msg_004", success=True)
assert msg.output == {}
def test_success_result_rejects_error(self):
"""ResultMessage success=true should not accept error payload."""
with pytest.raises(ValidationError) as exc_info:
ResultMessage(
id="msg_005",
success=True,
error=ErrorPayload(code="bad", message="bad"),
)
assert "success=true 时 error 必须为空" in str(exc_info.value)
def test_failed_result_requires_error(self):
"""ResultMessage success=false should require error payload."""
with pytest.raises(ValidationError) as exc_info:
ResultMessage(id="msg_006", success=False)
assert "success=false 时必须提供 error" in str(exc_info.value)
def test_failed_result_rejects_output(self):
"""ResultMessage success=false should not carry success output."""
with pytest.raises(ValidationError) as exc_info:
ResultMessage(
id="msg_007",
success=False,
output={"text": "bad"},
error=ErrorPayload(code="bad", message="bad"),
)
assert "success=false 时 output 必须为空" in str(exc_info.value)
class TestInvokeMessage:
"""Tests for InvokeMessage model."""
@@ -436,6 +463,16 @@ class TestParseMessage:
assert isinstance(msg, ResultMessage)
assert msg.success is True
def test_parse_pass_through_model(self):
"""parse_message should return already-parsed protocol models unchanged."""
original = InvokeMessage(id="msg_008", capability="test.cap")
assert parse_message(original) is original
def test_parse_non_mapping_raises(self):
"""parse_message should reject non-object payloads."""
with pytest.raises(ValueError, match="JSON object"):
parse_message(["not", "an", "object"])
def test_parse_unknown_type_raises(self):
"""parse_message should raise for unknown type."""
with pytest.raises(ValueError) as exc_info:

View File

@@ -0,0 +1,57 @@
"""Tests for protocol package exports."""
from __future__ import annotations
from astrbot_sdk.protocol import (
CapabilityDescriptor,
CommandTrigger,
ErrorPayload,
EventMessage,
HandlerDescriptor,
InitializeMessage,
LegacyAdapter,
LegacyRequest,
MessageTrigger,
PeerInfo,
ProtocolMessage,
ResultMessage,
ScheduleTrigger,
parse_legacy_message,
parse_message,
)
class TestProtocolPackageExports:
"""Ensure protocol package exposes the intended public surface."""
def test_core_exports_are_importable(self):
"""Core protocol models and parsers should be importable from package root."""
handler = HandlerDescriptor(
id="demo.handler",
trigger=CommandTrigger(command="hello"),
)
message = InitializeMessage(
id="msg-1",
protocol_version="1.0",
peer=PeerInfo(name="plugin", role="plugin"),
handlers=[handler],
)
parsed: ProtocolMessage = parse_message(message)
assert isinstance(parsed, InitializeMessage)
assert isinstance(ErrorPayload(code="x", message="y"), ErrorPayload)
assert isinstance(
CapabilityDescriptor(name="llm.chat", description="chat"),
CapabilityDescriptor,
)
assert isinstance(MessageTrigger(keywords=["hello"]), MessageTrigger)
assert isinstance(ScheduleTrigger(interval_seconds=60), ScheduleTrigger)
assert isinstance(EventMessage(id="evt-1", phase="started"), EventMessage)
assert isinstance(ResultMessage(id="res-1", success=True), ResultMessage)
def test_legacy_exports_are_importable(self):
"""Legacy adapter helpers should also be available from package root."""
legacy = parse_legacy_message({"jsonrpc": "2.0", "method": "handshake"})
assert isinstance(legacy, LegacyRequest)
assert isinstance(LegacyAdapter(), LegacyAdapter)