feat: 增强错误处理,添加上下文信息,优化插件组件加载和参数注入校验

This commit is contained in:
whatevertogo
2026-03-14 22:32:01 +08:00
parent 731fa6d5bd
commit 0ea532bd91
8 changed files with 319 additions and 48 deletions

View File

@@ -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,
)

View File

@@ -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__

View File

@@ -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],

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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")