feat: 添加稳定错误码常量,重构能力描述符以使用协议模式,更新相关测试用例

This commit is contained in:
whatevertogo
2026-03-13 04:46:00 +08:00
parent a106354e20
commit 9d05934d53
11 changed files with 406 additions and 122 deletions

View File

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

View File

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

View File

@@ -5,6 +5,28 @@ from __future__ import annotations
from dataclasses import dataclass
class ErrorCodes:
"""AstrBot v4 的稳定错误码常量。"""
UNKNOWN_ERROR = "unknown_error"
# retryable = False
LLM_NOT_CONFIGURED = "llm_not_configured"
CAPABILITY_NOT_FOUND = "capability_not_found"
PERMISSION_DENIED = "permission_denied"
LLM_ERROR = "llm_error"
INVALID_INPUT = "invalid_input"
CANCELLED = "cancelled"
PROTOCOL_VERSION_MISMATCH = "protocol_version_mismatch"
PROTOCOL_ERROR = "protocol_error"
INTERNAL_ERROR = "internal_error"
# retryable = True
CAPABILITY_TIMEOUT = "capability_timeout"
NETWORK_ERROR = "network_error"
LLM_TEMPORARY_ERROR = "llm_temporary_error"
@dataclass(slots=True)
class AstrBotError(Exception):
code: str
@@ -18,7 +40,7 @@ class AstrBotError(Exception):
@classmethod
def cancelled(cls, message: str = "调用被取消") -> "AstrBotError":
return cls(
code="cancelled",
code=ErrorCodes.CANCELLED,
message=message,
hint="",
retryable=False,
@@ -27,7 +49,7 @@ class AstrBotError(Exception):
@classmethod
def capability_not_found(cls, name: str) -> "AstrBotError":
return cls(
code="capability_not_found",
code=ErrorCodes.CAPABILITY_NOT_FOUND,
message=f"未找到能力:{name}",
hint="请确认 AstrBot Core 是否已注册该 capability",
retryable=False,
@@ -36,7 +58,7 @@ class AstrBotError(Exception):
@classmethod
def invalid_input(cls, message: str) -> "AstrBotError":
return cls(
code="invalid_input",
code=ErrorCodes.INVALID_INPUT,
message=message,
hint="请检查调用参数",
retryable=False,
@@ -45,7 +67,7 @@ class AstrBotError(Exception):
@classmethod
def protocol_version_mismatch(cls, message: str) -> "AstrBotError":
return cls(
code="protocol_version_mismatch",
code=ErrorCodes.PROTOCOL_VERSION_MISMATCH,
message=message,
hint="请升级 astrbot_sdk 至最新版本",
retryable=False,
@@ -54,7 +76,7 @@ class AstrBotError(Exception):
@classmethod
def protocol_error(cls, message: str) -> "AstrBotError":
return cls(
code="protocol_error",
code=ErrorCodes.PROTOCOL_ERROR,
message=message,
hint="请检查通信双方的协议实现",
retryable=False,
@@ -63,7 +85,7 @@ class AstrBotError(Exception):
@classmethod
def internal_error(cls, message: str) -> "AstrBotError":
return cls(
code="internal_error",
code=ErrorCodes.INTERNAL_ERROR,
message=message,
hint="请联系插件作者",
retryable=False,
@@ -80,7 +102,7 @@ class AstrBotError(Exception):
@classmethod
def from_payload(cls, payload: dict[str, object]) -> "AstrBotError":
return cls(
code=str(payload.get("code", "unknown_error")),
code=str(payload.get("code", ErrorCodes.UNKNOWN_ERROR)),
message=str(payload.get("message", "未知错误")),
hint=str(payload.get("hint", "")),
retryable=bool(payload.get("retryable", False)),

View File

@@ -11,6 +11,208 @@ from typing import Annotated, Any, Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
JSONSchema = dict[str, Any]
RESERVED_CAPABILITY_NAMESPACES = ("handler", "system", "internal")
RESERVED_CAPABILITY_PREFIXES = tuple(
f"{namespace}." for namespace in RESERVED_CAPABILITY_NAMESPACES
)
def _object_schema(
*,
required: tuple[str, ...] = (),
**properties: Any,
) -> JSONSchema:
return {
"type": "object",
"properties": properties,
"required": list(required),
}
def _nullable(schema: JSONSchema) -> JSONSchema:
return {"anyOf": [schema, {"type": "null"}]}
_OPTIONAL_CHAT_PROPERTIES: dict[str, Any] = {
"system": {"type": "string"},
"history": {"type": "array", "items": {"type": "object"}},
"model": {"type": "string"},
"temperature": {"type": "number"},
"image_urls": {"type": "array", "items": {"type": "string"}},
"tools": {"type": "array"},
"max_steps": {"type": "integer"},
}
LLM_CHAT_INPUT_SCHEMA = _object_schema(
required=("prompt",),
prompt={"type": "string"},
**_OPTIONAL_CHAT_PROPERTIES,
)
LLM_CHAT_OUTPUT_SCHEMA = _object_schema(
required=("text",),
text={"type": "string"},
)
LLM_CHAT_RAW_INPUT_SCHEMA = _object_schema(
required=("prompt",),
prompt={"type": "string"},
**_OPTIONAL_CHAT_PROPERTIES,
)
LLM_CHAT_RAW_OUTPUT_SCHEMA = _object_schema(
required=("text",),
text={"type": "string"},
usage=_nullable({"type": "object"}),
finish_reason=_nullable({"type": "string"}),
tool_calls={"type": "array", "items": {"type": "object"}},
)
LLM_STREAM_CHAT_INPUT_SCHEMA = _object_schema(
required=("prompt",),
prompt={"type": "string"},
**_OPTIONAL_CHAT_PROPERTIES,
)
LLM_STREAM_CHAT_OUTPUT_SCHEMA = _object_schema(
required=("text",),
text={"type": "string"},
)
MEMORY_SEARCH_INPUT_SCHEMA = _object_schema(
required=("query",),
query={"type": "string"},
)
MEMORY_SEARCH_OUTPUT_SCHEMA = _object_schema(
required=("items",),
items={"type": "array", "items": {"type": "object"}},
)
MEMORY_SAVE_INPUT_SCHEMA = _object_schema(
required=("key", "value"),
key={"type": "string"},
value={"type": "object"},
)
MEMORY_SAVE_OUTPUT_SCHEMA = _object_schema()
MEMORY_GET_INPUT_SCHEMA = _object_schema(
required=("key",),
key={"type": "string"},
)
MEMORY_GET_OUTPUT_SCHEMA = _object_schema(
required=("value",),
value=_nullable({"type": "object"}),
)
MEMORY_DELETE_INPUT_SCHEMA = _object_schema(
required=("key",),
key={"type": "string"},
)
MEMORY_DELETE_OUTPUT_SCHEMA = _object_schema()
DB_GET_INPUT_SCHEMA = _object_schema(
required=("key",),
key={"type": "string"},
)
DB_GET_OUTPUT_SCHEMA = _object_schema(
required=("value",),
value=_nullable({"type": "object"}),
)
DB_SET_INPUT_SCHEMA = _object_schema(
required=("key", "value"),
key={"type": "string"},
value={"type": "object"},
)
DB_SET_OUTPUT_SCHEMA = _object_schema()
DB_DELETE_INPUT_SCHEMA = _object_schema(
required=("key",),
key={"type": "string"},
)
DB_DELETE_OUTPUT_SCHEMA = _object_schema()
DB_LIST_INPUT_SCHEMA = _object_schema(
prefix=_nullable({"type": "string"}),
)
DB_LIST_OUTPUT_SCHEMA = _object_schema(
required=("keys",),
keys={"type": "array", "items": {"type": "string"}},
)
PLATFORM_SEND_INPUT_SCHEMA = _object_schema(
required=("session", "text"),
session={"type": "string"},
text={"type": "string"},
)
PLATFORM_SEND_OUTPUT_SCHEMA = _object_schema(
required=("message_id",),
message_id={"type": "string"},
)
PLATFORM_SEND_IMAGE_INPUT_SCHEMA = _object_schema(
required=("session", "image_url"),
session={"type": "string"},
image_url={"type": "string"},
)
PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA = _object_schema(
required=("message_id",),
message_id={"type": "string"},
)
PLATFORM_GET_MEMBERS_INPUT_SCHEMA = _object_schema(
required=("session",),
session={"type": "string"},
)
PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA = _object_schema(
required=("members",),
members={"type": "array", "items": {"type": "object"}},
)
BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = {
"llm.chat": {
"input": LLM_CHAT_INPUT_SCHEMA,
"output": LLM_CHAT_OUTPUT_SCHEMA,
},
"llm.chat_raw": {
"input": LLM_CHAT_RAW_INPUT_SCHEMA,
"output": LLM_CHAT_RAW_OUTPUT_SCHEMA,
},
"llm.stream_chat": {
"input": LLM_STREAM_CHAT_INPUT_SCHEMA,
"output": LLM_STREAM_CHAT_OUTPUT_SCHEMA,
},
"memory.search": {
"input": MEMORY_SEARCH_INPUT_SCHEMA,
"output": MEMORY_SEARCH_OUTPUT_SCHEMA,
},
"memory.save": {
"input": MEMORY_SAVE_INPUT_SCHEMA,
"output": MEMORY_SAVE_OUTPUT_SCHEMA,
},
"memory.get": {
"input": MEMORY_GET_INPUT_SCHEMA,
"output": MEMORY_GET_OUTPUT_SCHEMA,
},
"memory.delete": {
"input": MEMORY_DELETE_INPUT_SCHEMA,
"output": MEMORY_DELETE_OUTPUT_SCHEMA,
},
"db.get": {
"input": DB_GET_INPUT_SCHEMA,
"output": DB_GET_OUTPUT_SCHEMA,
},
"db.set": {
"input": DB_SET_INPUT_SCHEMA,
"output": DB_SET_OUTPUT_SCHEMA,
},
"db.delete": {
"input": DB_DELETE_INPUT_SCHEMA,
"output": DB_DELETE_OUTPUT_SCHEMA,
},
"db.list": {
"input": DB_LIST_INPUT_SCHEMA,
"output": DB_LIST_OUTPUT_SCHEMA,
},
"platform.send": {
"input": PLATFORM_SEND_INPUT_SCHEMA,
"output": PLATFORM_SEND_OUTPUT_SCHEMA,
},
"platform.send_image": {
"input": PLATFORM_SEND_IMAGE_INPUT_SCHEMA,
"output": PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA,
},
"platform.get_members": {
"input": PLATFORM_GET_MEMBERS_INPUT_SCHEMA,
"output": PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA,
},
}
class _DescriptorBase(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -193,19 +395,61 @@ class CapabilityDescriptor(_DescriptorBase):
name: str
description: str
input_schema: dict[str, Any] | None = None
output_schema: dict[str, Any] | None = None
input_schema: JSONSchema | None = None
output_schema: JSONSchema | None = None
supports_stream: bool = False
cancelable: bool = False
@model_validator(mode="after")
def validate_builtin_schema_governance(self) -> "CapabilityDescriptor":
if self.name in BUILTIN_CAPABILITY_SCHEMAS and (
self.input_schema is None or self.output_schema is None
):
raise ValueError(
f"内建 capability {self.name} 必须同时提供 input_schema 和 output_schema"
)
return self
__all__ = [
"BUILTIN_CAPABILITY_SCHEMAS",
"CapabilityDescriptor",
"CommandTrigger",
"DB_DELETE_INPUT_SCHEMA",
"DB_DELETE_OUTPUT_SCHEMA",
"DB_GET_INPUT_SCHEMA",
"DB_GET_OUTPUT_SCHEMA",
"DB_LIST_INPUT_SCHEMA",
"DB_LIST_OUTPUT_SCHEMA",
"DB_SET_INPUT_SCHEMA",
"DB_SET_OUTPUT_SCHEMA",
"EventTrigger",
"HandlerDescriptor",
"JSONSchema",
"LLM_CHAT_INPUT_SCHEMA",
"LLM_CHAT_OUTPUT_SCHEMA",
"LLM_CHAT_RAW_INPUT_SCHEMA",
"LLM_CHAT_RAW_OUTPUT_SCHEMA",
"LLM_STREAM_CHAT_INPUT_SCHEMA",
"LLM_STREAM_CHAT_OUTPUT_SCHEMA",
"MEMORY_DELETE_INPUT_SCHEMA",
"MEMORY_DELETE_OUTPUT_SCHEMA",
"MEMORY_GET_INPUT_SCHEMA",
"MEMORY_GET_OUTPUT_SCHEMA",
"MEMORY_SAVE_INPUT_SCHEMA",
"MEMORY_SAVE_OUTPUT_SCHEMA",
"MEMORY_SEARCH_INPUT_SCHEMA",
"MEMORY_SEARCH_OUTPUT_SCHEMA",
"MessageTrigger",
"PLATFORM_GET_MEMBERS_INPUT_SCHEMA",
"PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA",
"PLATFORM_SEND_IMAGE_INPUT_SCHEMA",
"PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA",
"PLATFORM_SEND_INPUT_SCHEMA",
"PLATFORM_SEND_OUTPUT_SCHEMA",
"Permissions",
"RESERVED_CAPABILITY_NAMESPACES",
"RESERVED_CAPABILITY_PREFIXES",
"ScheduleTrigger",
"Trigger",
]

View File

@@ -83,6 +83,7 @@
from __future__ import annotations
import asyncio
import copy
import json
import re
from collections.abc import AsyncIterator, Awaitable, Callable
@@ -90,12 +91,15 @@ from dataclasses import dataclass
from typing import Any
from ..errors import AstrBotError
from ..protocol.descriptors import CapabilityDescriptor
from ..protocol.descriptors import (
BUILTIN_CAPABILITY_SCHEMAS,
CapabilityDescriptor,
RESERVED_CAPABILITY_PREFIXES,
)
CallHandler = Callable[[str, dict[str, Any], object], Awaitable[dict[str, Any]]]
StreamHandler = Callable[[str, dict[str, Any], object], AsyncIterator[dict[str, Any]]]
FinalizeHandler = Callable[[list[dict[str, Any]]], dict[str, Any]]
RESERVED_CAPABILITY_PREFIXES = ("handler.", "system.", "internal.")
CAPABILITY_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$")
@@ -182,12 +186,22 @@ class CapabilityRouter:
return output
def _register_builtin_capabilities(self) -> None:
def obj_schema(required: list[str], **properties: Any) -> dict[str, Any]:
return {
"type": "object",
"properties": properties,
"required": required,
}
def builtin_descriptor(
name: str,
description: str,
*,
supports_stream: bool = False,
cancelable: bool = False,
) -> CapabilityDescriptor:
schema = BUILTIN_CAPABILITY_SCHEMAS[name]
return CapabilityDescriptor(
name=name,
description=description,
input_schema=copy.deepcopy(schema["input"]),
output_schema=copy.deepcopy(schema["output"]),
supports_stream=supports_stream,
cancelable=cancelable,
)
async def llm_chat(
_request_id: str, payload: dict[str, Any], _token
@@ -325,29 +339,17 @@ class CapabilityRouter:
}
self.register(
CapabilityDescriptor(
name="llm.chat",
description="发送对话请求,返回文本",
input_schema=obj_schema(["prompt"], prompt={"type": "string"}),
output_schema=obj_schema(["text"], text={"type": "string"}),
),
builtin_descriptor("llm.chat", "发送对话请求,返回文本"),
call_handler=llm_chat,
)
self.register(
CapabilityDescriptor(
name="llm.chat_raw",
description="发送对话请求,返回完整响应",
input_schema=obj_schema(["prompt"], prompt={"type": "string"}),
output_schema=obj_schema(["text"], text={"type": "string"}),
),
builtin_descriptor("llm.chat_raw", "发送对话请求,返回完整响应"),
call_handler=llm_chat_raw,
)
self.register(
CapabilityDescriptor(
name="llm.stream_chat",
description="流式对话",
input_schema=obj_schema(["prompt"], prompt={"type": "string"}),
output_schema=obj_schema(["text"], text={"type": "string"}),
builtin_descriptor(
"llm.stream_chat",
"流式对话",
supports_stream=True,
cancelable=True,
),
@@ -357,114 +359,47 @@ class CapabilityRouter:
},
)
self.register(
CapabilityDescriptor(
name="memory.search",
description="搜索记忆",
input_schema=obj_schema(["query"], query={"type": "string"}),
output_schema=obj_schema(["items"], items={"type": "array"}),
),
builtin_descriptor("memory.search", "搜索记忆"),
call_handler=memory_search,
)
self.register(
CapabilityDescriptor(
name="memory.save",
description="保存记忆",
input_schema=obj_schema(
["key", "value"], key={"type": "string"}, value={"type": "object"}
),
output_schema=obj_schema([]),
),
builtin_descriptor("memory.save", "保存记忆"),
call_handler=memory_save,
)
self.register(
CapabilityDescriptor(
name="memory.get",
description="读取单条记忆",
input_schema=obj_schema(["key"], key={"type": "string"}),
output_schema=obj_schema([], value={"type": "object"}),
),
builtin_descriptor("memory.get", "读取单条记忆"),
call_handler=memory_get,
)
self.register(
CapabilityDescriptor(
name="memory.delete",
description="删除记忆",
input_schema=obj_schema(["key"], key={"type": "string"}),
output_schema=obj_schema([]),
),
builtin_descriptor("memory.delete", "删除记忆"),
call_handler=memory_delete,
)
self.register(
CapabilityDescriptor(
name="db.get",
description="读取 KV",
input_schema=obj_schema(["key"], key={"type": "string"}),
output_schema=obj_schema([], value={"type": "object"}),
),
builtin_descriptor("db.get", "读取 KV"),
call_handler=db_get,
)
self.register(
CapabilityDescriptor(
name="db.set",
description="写入 KV",
input_schema=obj_schema(
["key", "value"], key={"type": "string"}, value={"type": "object"}
),
output_schema=obj_schema([]),
),
builtin_descriptor("db.set", "写入 KV"),
call_handler=db_set,
)
self.register(
CapabilityDescriptor(
name="db.delete",
description="删除 KV",
input_schema=obj_schema(["key"], key={"type": "string"}),
output_schema=obj_schema([]),
),
builtin_descriptor("db.delete", "删除 KV"),
call_handler=db_delete,
)
self.register(
CapabilityDescriptor(
name="db.list",
description="列出 KV",
input_schema=obj_schema([], prefix={"type": "string"}),
output_schema=obj_schema(["keys"], keys={"type": "array"}),
),
builtin_descriptor("db.list", "列出 KV"),
call_handler=db_list,
)
self.register(
CapabilityDescriptor(
name="platform.send",
description="发送消息",
input_schema=obj_schema(
["session", "text"],
session={"type": "string"},
text={"type": "string"},
),
output_schema=obj_schema(["message_id"], message_id={"type": "string"}),
),
builtin_descriptor("platform.send", "发送消息"),
call_handler=platform_send,
)
self.register(
CapabilityDescriptor(
name="platform.send_image",
description="发送图片",
input_schema=obj_schema(
["session", "image_url"],
session={"type": "string"},
image_url={"type": "string"},
),
output_schema=obj_schema(["message_id"], message_id={"type": "string"}),
),
builtin_descriptor("platform.send_image", "发送图片"),
call_handler=platform_send_image,
)
self.register(
CapabilityDescriptor(
name="platform.get_members",
description="获取群成员",
input_schema=obj_schema(["session"], session={"type": "string"}),
output_schema=obj_schema(["members"], members={"type": "array"}),
),
builtin_descriptor("platform.get_members", "获取群成员"),
call_handler=platform_get_members,
)
@@ -473,10 +408,28 @@ class CapabilityRouter:
schema: dict[str, Any] | None,
payload: dict[str, Any],
) -> None:
def schema_allows_null(field_schema: Any) -> bool:
if not isinstance(field_schema, dict):
return False
if field_schema.get("type") == "null":
return True
any_of = field_schema.get("anyOf")
if not isinstance(any_of, list):
return False
return any(
isinstance(candidate, dict) and candidate.get("type") == "null"
for candidate in any_of
)
if schema is None:
return
if schema.get("type") == "object" and not isinstance(payload, dict):
raise AstrBotError.invalid_input("输入必须是 object")
properties = schema.get("properties", {})
for field_name in schema.get("required", []):
if field_name not in payload or payload[field_name] is None:
if field_name not in payload:
raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}")
if payload[field_name] is None and not schema_allows_null(
properties.get(field_name)
):
raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}")

View File

@@ -23,7 +23,7 @@
invoke() -> InvokeMessage(stream=False)
invoke_stream() -> InvokeMessage(stream=True)
cancel() -> CancelMessage
与旧版对比:
旧版 JSON-RPC:
- 分离的 JSONRPCClient 和 JSONRPCServer

View File

@@ -10,7 +10,10 @@ import pytest
from astrbot_sdk.context import CancelToken
from astrbot_sdk.errors import AstrBotError
from astrbot_sdk.protocol.descriptors import CapabilityDescriptor
from astrbot_sdk.protocol.descriptors import (
BUILTIN_CAPABILITY_SCHEMAS,
CapabilityDescriptor,
)
from astrbot_sdk.runtime.capability_router import (
CAPABILITY_NAME_PATTERN,
RESERVED_CAPABILITY_PREFIXES,
@@ -167,6 +170,17 @@ class TestCapabilityRouterInit:
assert "platform.send_image" in capability_names
assert "platform.get_members" in capability_names
def test_builtin_descriptors_use_protocol_schema_registry(self):
"""CapabilityRouter should source built-in schemas from protocol constants."""
router = CapabilityRouter()
descriptors = {
descriptor.name: descriptor for descriptor in router.descriptors()
}
for name, schema in BUILTIN_CAPABILITY_SCHEMAS.items():
assert descriptors[name].input_schema == schema["input"]
assert descriptors[name].output_schema == schema["output"]
class TestCapabilityRouterRegister:
"""Tests for CapabilityRouter.register method."""

View File

@@ -8,12 +8,16 @@ import pytest
from pydantic import ValidationError
from astrbot_sdk.protocol.descriptors import (
BUILTIN_CAPABILITY_SCHEMAS,
CapabilityDescriptor,
CommandTrigger,
DB_LIST_INPUT_SCHEMA,
EventTrigger,
HandlerDescriptor,
LLM_CHAT_INPUT_SCHEMA,
MessageTrigger,
Permissions,
RESERVED_CAPABILITY_PREFIXES,
ScheduleTrigger,
)
@@ -256,14 +260,32 @@ class TestCapabilityDescriptor:
def test_required_name_and_description(self):
"""CapabilityDescriptor requires name and description."""
cap = CapabilityDescriptor(name="llm.chat", description="Chat with LLM")
assert cap.name == "llm.chat"
cap = CapabilityDescriptor(name="custom.chat", description="Chat with LLM")
assert cap.name == "custom.chat"
assert cap.description == "Chat with LLM"
assert cap.input_schema is None
assert cap.output_schema is None
assert cap.supports_stream is False
assert cap.cancelable is False
def test_builtin_capability_requires_schemas(self):
"""Built-in capabilities should enforce schema governance."""
with pytest.raises(ValidationError, match="必须同时提供"):
CapabilityDescriptor(name="llm.chat", description="missing schemas")
def test_builtin_capability_schema_registry_contains_required_entries(self):
"""Built-in schema registry should cover documented core capabilities."""
assert "llm.chat" in BUILTIN_CAPABILITY_SCHEMAS
assert "db.list" in BUILTIN_CAPABILITY_SCHEMAS
assert LLM_CHAT_INPUT_SCHEMA["required"] == ["prompt"]
assert (
DB_LIST_INPUT_SCHEMA["properties"]["prefix"]["anyOf"][1]["type"] == "null"
)
def test_reserved_capability_prefixes_are_protocol_constants(self):
"""Reserved namespace prefixes should live in protocol descriptors."""
assert RESERVED_CAPABILITY_PREFIXES == ("handler.", "system.", "internal.")
def test_with_schemas(self):
"""CapabilityDescriptor should accept input/output schemas."""
cap = CapabilityDescriptor(

View File

@@ -187,10 +187,18 @@ class TestInitializeOutput:
def test_with_capabilities(self):
"""InitializeOutput should accept capabilities."""
from astrbot_sdk.protocol.descriptors import CapabilityDescriptor
from astrbot_sdk.protocol.descriptors import (
BUILTIN_CAPABILITY_SCHEMAS,
CapabilityDescriptor,
)
peer = PeerInfo(name="core", role="core")
cap = CapabilityDescriptor(name="llm.chat", description="Chat capability")
cap = CapabilityDescriptor(
name="llm.chat",
description="Chat capability",
input_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"],
output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"],
)
output = InitializeOutput(peer=peer, capabilities=[cap])
assert len(output.capabilities) == 1
assert output.capabilities[0].name == "llm.chat"

View File

@@ -19,6 +19,7 @@ from astrbot_sdk.protocol import (
parse_legacy_message,
parse_message,
)
from astrbot_sdk.protocol.descriptors import BUILTIN_CAPABILITY_SCHEMAS
class TestProtocolPackageExports:
@@ -41,7 +42,12 @@ class TestProtocolPackageExports:
assert isinstance(parsed, InitializeMessage)
assert isinstance(ErrorPayload(code="x", message="y"), ErrorPayload)
assert isinstance(
CapabilityDescriptor(name="llm.chat", description="chat"),
CapabilityDescriptor(
name="llm.chat",
description="chat",
input_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"],
output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"],
),
CapabilityDescriptor,
)
assert isinstance(MessageTrigger(keywords=["hello"]), MessageTrigger)

View File

@@ -23,7 +23,7 @@ from astrbot_sdk._legacy_api import (
from astrbot_sdk.cli import cli, setup_logger
from astrbot_sdk.context import Context
from astrbot_sdk.decorators import on_command
from astrbot_sdk.errors import AstrBotError
from astrbot_sdk.errors import AstrBotError, ErrorCodes
from astrbot_sdk.events import MessageEvent
from astrbot_sdk.star import Star
@@ -224,11 +224,24 @@ class TestErrorsModule:
"""from_payload() should fill in the documented fallback values."""
error = AstrBotError.from_payload({"message": "boom", "retryable": 1})
assert error.code == "unknown_error"
assert error.code == ErrorCodes.UNKNOWN_ERROR
assert error.message == "boom"
assert error.hint == ""
assert error.retryable is True
def test_error_code_constants_match_factory_outputs(self):
"""核心工厂方法应复用统一错误码常量。"""
assert AstrBotError.cancelled().code == ErrorCodes.CANCELLED
assert (
AstrBotError.capability_not_found("memory.get").code
== ErrorCodes.CAPABILITY_NOT_FOUND
)
assert AstrBotError.invalid_input("bad").code == ErrorCodes.INVALID_INPUT
assert (
AstrBotError.protocol_version_mismatch("bad").code
== ErrorCodes.PROTOCOL_VERSION_MISMATCH
)
class TestStarModule:
"""Tests for star.py."""