mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: 增强错误处理,添加上下文信息,优化插件组件加载和参数注入校验
This commit is contained in:
@@ -56,11 +56,16 @@ class AstrBotError(Exception):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def invalid_input(cls, message: str) -> "AstrBotError":
|
||||
def invalid_input(
|
||||
cls,
|
||||
message: str,
|
||||
*,
|
||||
hint: str = "请检查调用参数",
|
||||
) -> "AstrBotError":
|
||||
return cls(
|
||||
code=ErrorCodes.INVALID_INPUT,
|
||||
message=message,
|
||||
hint="请检查调用参数",
|
||||
hint=hint,
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
@@ -83,11 +88,16 @@ class AstrBotError(Exception):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def internal_error(cls, message: str) -> "AstrBotError":
|
||||
def internal_error(
|
||||
cls,
|
||||
message: str,
|
||||
*,
|
||||
hint: str = "请联系插件作者",
|
||||
) -> "AstrBotError":
|
||||
return cls(
|
||||
code=ErrorCodes.INTERNAL_ERROR,
|
||||
message=message,
|
||||
hint="请联系插件作者",
|
||||
hint=hint,
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -260,7 +260,12 @@ class CapabilityRouter:
|
||||
if registration is None:
|
||||
raise AstrBotError.capability_not_found(capability)
|
||||
|
||||
self._validate_schema(registration.descriptor.input_schema, payload)
|
||||
self._validate_schema_with_context(
|
||||
capability=capability,
|
||||
phase="输入",
|
||||
schema=registration.descriptor.input_schema,
|
||||
payload=payload,
|
||||
)
|
||||
if stream:
|
||||
if registration.stream_handler is None:
|
||||
raise AstrBotError.invalid_input(f"{capability} 不支持 stream=true")
|
||||
@@ -286,7 +291,12 @@ class CapabilityRouter:
|
||||
if registration.call_handler is None:
|
||||
raise AstrBotError.invalid_input(f"{capability} 只能以 stream=true 调用")
|
||||
output = await registration.call_handler(request_id, payload, cancel_token)
|
||||
self._validate_schema(registration.descriptor.output_schema, output)
|
||||
self._validate_schema_with_context(
|
||||
capability=capability,
|
||||
phase="输出",
|
||||
schema=registration.descriptor.output_schema,
|
||||
payload=output,
|
||||
)
|
||||
return output
|
||||
|
||||
def _wrap_stream_execution(
|
||||
@@ -296,7 +306,12 @@ class CapabilityRouter:
|
||||
) -> StreamExecution:
|
||||
def validated_finalize(chunks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
output = execution.finalize(chunks)
|
||||
self._validate_schema(descriptor.output_schema, output)
|
||||
self._validate_schema_with_context(
|
||||
capability=descriptor.name,
|
||||
phase="输出",
|
||||
schema=descriptor.output_schema,
|
||||
payload=output,
|
||||
)
|
||||
return output
|
||||
|
||||
return StreamExecution(
|
||||
@@ -911,6 +926,26 @@ class CapabilityRouter:
|
||||
return
|
||||
self._validate_value(schema, payload, path="")
|
||||
|
||||
def _validate_schema_with_context(
|
||||
self,
|
||||
*,
|
||||
capability: str,
|
||||
phase: str,
|
||||
schema: dict[str, Any] | None,
|
||||
payload: Any,
|
||||
) -> None:
|
||||
try:
|
||||
self._validate_schema(schema, payload)
|
||||
except AstrBotError as exc:
|
||||
if exc.code != "invalid_input":
|
||||
raise
|
||||
raise AstrBotError.invalid_input(
|
||||
f"capability '{capability}' 的{phase}校验失败:{exc.message}",
|
||||
hint=(
|
||||
f"请检查 capability '{capability}' 的{phase.lower()}是否符合声明的 schema"
|
||||
),
|
||||
) from exc
|
||||
|
||||
def _validate_value(
|
||||
self,
|
||||
schema: dict[str, Any],
|
||||
@@ -929,20 +964,26 @@ class CapabilityRouter:
|
||||
except AstrBotError:
|
||||
continue
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 不符合允许的 schema 约束"
|
||||
f"{self._field_label(path)} 不符合允许的 schema 约束,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list) and value not in enum:
|
||||
raise AstrBotError.invalid_input(f"{self._field_label(path)} 必须是 {enum}")
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 {enum},实际收到 {value!r}"
|
||||
)
|
||||
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "object":
|
||||
if not isinstance(value, dict):
|
||||
if not path:
|
||||
raise AstrBotError.invalid_input("输入必须是 object")
|
||||
raise AstrBotError.invalid_input(
|
||||
f"输入必须是 object,实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 object"
|
||||
f"{self._field_label(path)} 必须是 object,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
properties = schema.get("properties", {})
|
||||
required_fields = schema.get("required", [])
|
||||
@@ -973,7 +1014,8 @@ class CapabilityRouter:
|
||||
if schema_type == "array":
|
||||
if not isinstance(value, list):
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 array"
|
||||
f"{self._field_label(path)} 必须是 array,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
item_schema = schema.get("items")
|
||||
if isinstance(item_schema, dict):
|
||||
@@ -988,35 +1030,40 @@ class CapabilityRouter:
|
||||
if schema_type == "string":
|
||||
if not isinstance(value, str):
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 string"
|
||||
f"{self._field_label(path)} 必须是 string,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
return
|
||||
|
||||
if schema_type == "integer":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 integer"
|
||||
f"{self._field_label(path)} 必须是 integer,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
return
|
||||
|
||||
if schema_type == "number":
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 number"
|
||||
f"{self._field_label(path)} 必须是 number,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
return
|
||||
|
||||
if schema_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 boolean"
|
||||
f"{self._field_label(path)} 必须是 boolean,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
return
|
||||
|
||||
if schema_type == "null":
|
||||
if value is not None:
|
||||
raise AstrBotError.invalid_input(
|
||||
f"{self._field_label(path)} 必须是 null"
|
||||
f"{self._field_label(path)} 必须是 null,"
|
||||
f"实际收到 {self._value_type_name(value)}"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1061,3 +1108,21 @@ class CapabilityRouter:
|
||||
isinstance(candidate, dict) and candidate.get("type") == "null"
|
||||
for candidate in any_of
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _value_type_name(value: Any) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return type(value).__name__
|
||||
|
||||
@@ -106,7 +106,14 @@ class HandlerDispatcher:
|
||||
) -> None:
|
||||
try:
|
||||
result = loaded.callable(
|
||||
*self._build_args(loaded.callable, event, ctx, args)
|
||||
*self._build_args(
|
||||
loaded.callable,
|
||||
event,
|
||||
ctx,
|
||||
args,
|
||||
plugin_id=self._resolve_plugin_id(loaded),
|
||||
handler_ref=loaded.descriptor.id,
|
||||
)
|
||||
)
|
||||
if inspect.isasyncgen(result):
|
||||
async for item in result:
|
||||
@@ -133,6 +140,9 @@ class HandlerDispatcher:
|
||||
event: MessageEvent,
|
||||
ctx: Context,
|
||||
args: dict[str, Any] | None = None,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
handler_ref: str | None = None,
|
||||
) -> list[Any]:
|
||||
"""构建 handler 参数列表。"""
|
||||
from loguru import logger
|
||||
@@ -180,8 +190,13 @@ class HandlerDispatcher:
|
||||
parameter.name,
|
||||
)
|
||||
raise TypeError(
|
||||
f"handler '{handler.__name__}' 的必填参数 "
|
||||
f"'{parameter.name}' 无法注入"
|
||||
self._format_handler_injection_error(
|
||||
handler=handler,
|
||||
parameter_name=parameter.name,
|
||||
plugin_id=plugin_id,
|
||||
handler_ref=handler_ref,
|
||||
args=args,
|
||||
)
|
||||
)
|
||||
else:
|
||||
injected_args.append(injected)
|
||||
@@ -219,6 +234,36 @@ class HandlerDispatcher:
|
||||
|
||||
return None
|
||||
|
||||
def _format_handler_injection_error(
|
||||
self,
|
||||
*,
|
||||
handler,
|
||||
parameter_name: str,
|
||||
plugin_id: str | None,
|
||||
handler_ref: str | None,
|
||||
args: dict[str, Any],
|
||||
) -> str:
|
||||
plugin_text = plugin_id or self._plugin_id
|
||||
target = handler_ref or getattr(handler, "__name__", "<anonymous>")
|
||||
arg_keys = sorted(str(key) for key in args.keys())
|
||||
arg_keys_text = ", ".join(arg_keys) if arg_keys else "<none>"
|
||||
return (
|
||||
f"插件 '{plugin_text}' 的 handler '{target}' 参数注入失败:"
|
||||
f"必填参数 '{parameter_name}' 无法注入。"
|
||||
f"签名: {getattr(handler, '__name__', '<anonymous>')}"
|
||||
f"{self._callable_signature(handler)}。"
|
||||
"当前支持按类型注入 MessageEvent / Context,"
|
||||
"按参数名注入 event / ctx / context,"
|
||||
f"以及 args 中现有键:{arg_keys_text}。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _callable_signature(handler) -> str:
|
||||
try:
|
||||
return str(inspect.signature(handler))
|
||||
except (TypeError, ValueError):
|
||||
return "(...)"
|
||||
|
||||
async def _send_result(
|
||||
self,
|
||||
item: Any,
|
||||
@@ -325,7 +370,14 @@ class CapabilityDispatcher:
|
||||
stream: bool,
|
||||
) -> dict[str, Any] | StreamExecution:
|
||||
result = loaded.callable(
|
||||
*self._build_args(loaded.callable, payload, ctx, cancel_token)
|
||||
*self._build_args(
|
||||
loaded.callable,
|
||||
payload,
|
||||
ctx,
|
||||
cancel_token,
|
||||
plugin_id=self._resolve_plugin_id(loaded),
|
||||
capability_name=loaded.descriptor.name,
|
||||
)
|
||||
)
|
||||
if stream:
|
||||
if inspect.isasyncgen(result):
|
||||
@@ -355,6 +407,9 @@ class CapabilityDispatcher:
|
||||
payload: dict[str, Any],
|
||||
ctx: Context,
|
||||
cancel_token: CancelToken,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
capability_name: str | None = None,
|
||||
) -> list[Any]:
|
||||
signature = inspect.signature(handler)
|
||||
args: list[Any] = []
|
||||
@@ -389,8 +444,13 @@ class CapabilityDispatcher:
|
||||
if parameter.default is not parameter.empty:
|
||||
continue
|
||||
raise TypeError(
|
||||
f"capability '{handler.__name__}' 的必填参数 "
|
||||
f"'{parameter.name}' 无法注入"
|
||||
self._format_capability_injection_error(
|
||||
handler=handler,
|
||||
parameter_name=parameter.name,
|
||||
plugin_id=plugin_id,
|
||||
capability_name=capability_name,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
args.append(injected)
|
||||
|
||||
@@ -423,6 +483,29 @@ class CapabilityDispatcher:
|
||||
return payload
|
||||
return None
|
||||
|
||||
def _format_capability_injection_error(
|
||||
self,
|
||||
*,
|
||||
handler,
|
||||
parameter_name: str,
|
||||
plugin_id: str | None,
|
||||
capability_name: str | None,
|
||||
payload: dict[str, Any],
|
||||
) -> str:
|
||||
plugin_text = plugin_id or self._plugin_id
|
||||
target = capability_name or getattr(handler, "__name__", "<anonymous>")
|
||||
payload_keys = sorted(str(key) for key in payload.keys())
|
||||
payload_keys_text = ", ".join(payload_keys) if payload_keys else "<none>"
|
||||
return (
|
||||
f"插件 '{plugin_text}' 的 capability '{target}' 参数注入失败:"
|
||||
f"必填参数 '{parameter_name}' 无法注入。"
|
||||
f"签名: {getattr(handler, '__name__', '<anonymous>')}"
|
||||
f"{HandlerDispatcher._callable_signature(handler)}。"
|
||||
"当前支持按类型注入 Context / CancelToken / dict,"
|
||||
"按参数名注入 ctx / context / payload / input / data / cancel_token / token,"
|
||||
f"以及 payload 中现有键:{payload_keys_text}。"
|
||||
)
|
||||
|
||||
async def _iterate_generator(
|
||||
self,
|
||||
generator: AsyncIterator[Any],
|
||||
|
||||
@@ -131,6 +131,13 @@ class LoadedPlugin:
|
||||
instances: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ResolvedComponent:
|
||||
cls: type[Any]
|
||||
class_path: str
|
||||
index: int
|
||||
|
||||
|
||||
def _iter_handler_names(instance: Any) -> list[str]:
|
||||
handler_names = getattr(instance.__class__, "__handlers__", ())
|
||||
if handler_names:
|
||||
@@ -145,6 +152,14 @@ def _iter_discoverable_names(instance: Any) -> list[str]:
|
||||
return [*handler_names, *extra_names]
|
||||
|
||||
|
||||
def _plugin_context(plugin: PluginSpec) -> str:
|
||||
return f"插件 '{plugin.name}'({plugin.manifest_path})"
|
||||
|
||||
|
||||
def _component_context(plugin: PluginSpec, *, class_path: str, index: int) -> str:
|
||||
return f"{_plugin_context(plugin)} 的 components[{index}].class='{class_path}'"
|
||||
|
||||
|
||||
def _resolve_handler_candidate(instance: Any, name: str) -> tuple[Any, Any] | None:
|
||||
"""解析 handler 名称,避免在扫描阶段触发无关 descriptor 副作用。"""
|
||||
try:
|
||||
@@ -309,25 +324,48 @@ def _is_new_star_component(cls: type[Any]) -> bool:
|
||||
return bool(getattr(cls, "__astrbot_is_new_star__", False))
|
||||
|
||||
|
||||
def _plugin_component_classes(plugin: PluginSpec) -> list[type[Any]]:
|
||||
def _plugin_component_classes(plugin: PluginSpec) -> list[_ResolvedComponent]:
|
||||
"""解析插件组件类列表。"""
|
||||
components = plugin.manifest_data.get("components") or []
|
||||
if not isinstance(components, list):
|
||||
return []
|
||||
|
||||
classes: list[type[Any]] = []
|
||||
for component in components:
|
||||
classes: list[_ResolvedComponent] = []
|
||||
for index, component in enumerate(components):
|
||||
if not isinstance(component, dict):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"{_plugin_context(plugin)} 的 components[{index}] 必须是 object。"
|
||||
)
|
||||
class_path = component.get("class")
|
||||
if not isinstance(class_path, str) or ":" not in class_path:
|
||||
continue
|
||||
raise ValueError(
|
||||
f"{_plugin_context(plugin)} 的 components[{index}].class "
|
||||
"必须是 '<module>:<Class>'。"
|
||||
)
|
||||
try:
|
||||
cls = import_string(class_path, plugin.plugin_dir)
|
||||
if isinstance(cls, type):
|
||||
classes.append(cls)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"{_component_context(plugin, class_path=class_path, index=index)} "
|
||||
f"加载失败:{exc}"
|
||||
) from exc
|
||||
if not isinstance(cls, type):
|
||||
raise ValueError(
|
||||
f"{_component_context(plugin, class_path=class_path, index=index)} "
|
||||
"解析结果不是类,请检查导出名称。"
|
||||
)
|
||||
classes.append(
|
||||
_ResolvedComponent(
|
||||
cls=cls,
|
||||
class_path=class_path,
|
||||
index=index,
|
||||
)
|
||||
)
|
||||
if not classes:
|
||||
raise ValueError(
|
||||
f"{_plugin_context(plugin)} 未声明任何可加载组件。"
|
||||
"请检查 plugin.yaml 中的 components 配置。"
|
||||
)
|
||||
return classes
|
||||
|
||||
|
||||
@@ -338,7 +376,7 @@ def load_plugin_spec(plugin_dir: Path) -> PluginSpec:
|
||||
requirements_path = plugin_dir / "requirements.txt"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise ValueError(f"missing {PLUGIN_MANIFEST_FILE}")
|
||||
raise ValueError(f"插件目录 '{plugin_dir}' 缺少 {PLUGIN_MANIFEST_FILE}。")
|
||||
|
||||
manifest_data = _read_yaml(manifest_path)
|
||||
runtime = manifest_data.get("runtime") or {}
|
||||
@@ -357,29 +395,33 @@ def load_plugin_spec(plugin_dir: Path) -> PluginSpec:
|
||||
def validate_plugin_spec(plugin: PluginSpec) -> None:
|
||||
"""校验单个插件规范,供 CLI 和发现流程复用。"""
|
||||
manifest_data = plugin.manifest_data
|
||||
manifest_label = f"插件 '{plugin.name}'({plugin.manifest_path})"
|
||||
|
||||
if not plugin.requirements_path.exists():
|
||||
raise ValueError("missing requirements.txt")
|
||||
raise ValueError(f"{manifest_label} 缺少 requirements.txt。")
|
||||
|
||||
raw_name = manifest_data.get("name")
|
||||
if not isinstance(raw_name, str) or not raw_name:
|
||||
raise ValueError("plugin name is required")
|
||||
raise ValueError(f"{manifest_label} 缺少 name。")
|
||||
|
||||
raw_runtime = manifest_data.get("runtime") or {}
|
||||
raw_python = raw_runtime.get("python")
|
||||
if not isinstance(raw_python, str) or not raw_python:
|
||||
raise ValueError("runtime.python is required")
|
||||
raise ValueError(f"{manifest_label} 缺少 runtime.python。")
|
||||
|
||||
components = manifest_data.get("components")
|
||||
if not isinstance(components, list):
|
||||
raise ValueError("components must be a list")
|
||||
raise ValueError(f"{manifest_label} 的 components 必须是数组。")
|
||||
|
||||
for index, component in enumerate(components):
|
||||
if not isinstance(component, dict):
|
||||
raise ValueError(f"components[{index}] must be an object")
|
||||
raise ValueError(f"{manifest_label} 的 components[{index}] 必须是 object。")
|
||||
class_path = component.get("class")
|
||||
if not isinstance(class_path, str) or ":" not in class_path:
|
||||
raise ValueError(f"components[{index}].class must be '<module>:<Class>'")
|
||||
raise ValueError(
|
||||
f"{manifest_label} 的 components[{index}].class "
|
||||
"必须是 '<module>:<Class>'。"
|
||||
)
|
||||
|
||||
|
||||
def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
|
||||
@@ -548,13 +590,21 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin:
|
||||
handlers: list[LoadedHandler] = []
|
||||
capabilities: list[LoadedCapability] = []
|
||||
|
||||
for component_cls in _plugin_component_classes(plugin):
|
||||
for resolved_component in _plugin_component_classes(plugin):
|
||||
component_cls = resolved_component.cls
|
||||
if not _is_new_star_component(component_cls):
|
||||
raise ValueError(
|
||||
f"组件 {component_cls.__name__} 不是 v4 Star 组件。"
|
||||
"旧版插件请使用 AstrBot 主程序运行。"
|
||||
f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} "
|
||||
f"解析到的类 {component_cls.__module__}.{component_cls.__qualname__} "
|
||||
"不是 v4 Star 组件。请继承 astrbot_sdk.Star。"
|
||||
)
|
||||
instance = component_cls()
|
||||
try:
|
||||
instance = component_cls()
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"{_component_context(plugin, class_path=resolved_component.class_path, index=resolved_component.index)} "
|
||||
f"实例化失败:{exc}"
|
||||
) from exc
|
||||
instances.append(instance)
|
||||
|
||||
for name in _iter_discoverable_names(instance):
|
||||
|
||||
@@ -41,6 +41,7 @@ from .runtime.loader import (
|
||||
load_plugin,
|
||||
load_plugin_config,
|
||||
load_plugin_spec,
|
||||
validate_plugin_spec,
|
||||
)
|
||||
from .star import Star
|
||||
|
||||
@@ -563,6 +564,7 @@ class PluginHarness:
|
||||
return
|
||||
try:
|
||||
self.plugin = load_plugin_spec(self.config.plugin_dir)
|
||||
validate_plugin_spec(self.plugin)
|
||||
self.loaded_plugin = load_plugin(self.plugin)
|
||||
except Exception as exc: # pragma: no cover - 由 CLI/集成测试覆盖
|
||||
raise _PluginLoadError(str(exc)) from exc
|
||||
|
||||
@@ -335,7 +335,7 @@ class TestCapabilityRouterExecute:
|
||||
token = CancelToken()
|
||||
|
||||
# Missing required field
|
||||
with pytest.raises(AstrBotError, match="缺少必填字段"):
|
||||
with pytest.raises(AstrBotError) as raised:
|
||||
await router.execute(
|
||||
"test.cap",
|
||||
{},
|
||||
@@ -343,6 +343,9 @@ class TestCapabilityRouterExecute:
|
||||
cancel_token=token,
|
||||
request_id="req-1",
|
||||
)
|
||||
message = str(raised.value)
|
||||
assert "capability 'test.cap' 的输入校验失败" in message
|
||||
assert "缺少必填字段:name" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_validates_output_schema(self):
|
||||
@@ -363,7 +366,7 @@ class TestCapabilityRouterExecute:
|
||||
|
||||
token = CancelToken()
|
||||
|
||||
with pytest.raises(AstrBotError, match="缺少必填字段"):
|
||||
with pytest.raises(AstrBotError) as raised:
|
||||
await router.execute(
|
||||
"test.cap",
|
||||
{},
|
||||
@@ -371,6 +374,9 @@ class TestCapabilityRouterExecute:
|
||||
cancel_token=token,
|
||||
request_id="req-1",
|
||||
)
|
||||
message = str(raised.value)
|
||||
assert "capability 'test.cap' 的输出校验失败" in message
|
||||
assert "缺少必填字段:result" in message
|
||||
|
||||
|
||||
class TestCapabilityRouterDBWatch:
|
||||
|
||||
@@ -32,8 +32,12 @@ class TestHandlerDispatcherArgumentValidation:
|
||||
async def bad_handler(event: MessageEvent, missing: str) -> None:
|
||||
return None
|
||||
|
||||
with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"):
|
||||
with pytest.raises(TypeError) as raised:
|
||||
dispatcher._build_args(bad_handler, event, ctx, args={})
|
||||
message = str(raised.value)
|
||||
assert "插件 'demo' 的 handler" in message
|
||||
assert "必填参数 'missing' 无法注入" in message
|
||||
assert "MessageEvent / Context" in message
|
||||
|
||||
def test_capability_dispatcher_raises_for_uninjectable_required_param(self):
|
||||
peer = _peer()
|
||||
@@ -50,13 +54,17 @@ class TestHandlerDispatcherArgumentValidation:
|
||||
)
|
||||
ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken())
|
||||
|
||||
with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"):
|
||||
with pytest.raises(TypeError) as raised:
|
||||
dispatcher._build_args(
|
||||
capability.callable,
|
||||
payload={},
|
||||
ctx=ctx,
|
||||
cancel_token=CancelToken(),
|
||||
)
|
||||
message = str(raised.value)
|
||||
assert "插件 'demo' 的 capability" in message
|
||||
assert "必填参数 'missing' 无法注入" in message
|
||||
assert "Context / CancelToken / dict" in message
|
||||
|
||||
|
||||
class TestHandlerDispatcherInvoke:
|
||||
@@ -85,8 +93,11 @@ class TestHandlerDispatcherInvoke:
|
||||
"event": {"text": "hello", "session_id": "s1"},
|
||||
}
|
||||
|
||||
with pytest.raises(TypeError, match="必填参数 'missing' 无法注入"):
|
||||
with pytest.raises(TypeError) as raised:
|
||||
await dispatcher.invoke(Message(), CancelToken())
|
||||
message = str(raised.value)
|
||||
assert "demo:plugin.bad_handler" in message
|
||||
assert "必填参数 'missing' 无法注入" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_binds_runtime_caller_plugin_id_for_raw_peer_calls(self):
|
||||
|
||||
@@ -85,6 +85,50 @@ async def test_plugin_harness_supports_metadata_and_http_commands() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_harness_reports_component_load_errors(tmp_path: Path) -> None:
|
||||
from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness, _PluginLoadError
|
||||
|
||||
plugin_dir = tmp_path / "broken-plugin"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "requirements.txt").write_text("", encoding="utf-8")
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"name: broken_demo",
|
||||
"display_name: Broken Demo",
|
||||
"author: test",
|
||||
"version: 0.1.0",
|
||||
"runtime:",
|
||||
' python: "3.13"',
|
||||
"components:",
|
||||
" - class: main:MissingComponent",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(plugin_dir / "main.py").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"from astrbot_sdk import Star",
|
||||
"",
|
||||
"class PresentComponent(Star):",
|
||||
" pass",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
harness = PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir))
|
||||
with pytest.raises(_PluginLoadError) as raised:
|
||||
await harness.start()
|
||||
message = str(raised.value)
|
||||
assert "插件 'broken_demo'" in message
|
||||
assert "components[0].class='main:MissingComponent'" in message
|
||||
assert "加载失败" in message
|
||||
|
||||
|
||||
def _write_watch_plugin(plugin_dir: Path, *, reply_text: str) -> None:
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "requirements.txt").write_text("", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user