Files
AstrBot/src-new/astrbot_sdk/runtime/handler_dispatcher.py
whatevertogo 9c5ecf0a13 feat: Enhance plugin capability support and legacy compatibility
- Introduced LoadedCapability class to manage plugin capabilities.
- Updated load_plugin function to discover and load capabilities alongside handlers.
- Enhanced Peer class to handle remote provided capabilities during initialization.
- Added tests for capability registration and invocation in legacy plugins.
- Improved MessageChain and message component handling in legacy plugins.
- Added comprehensive tests for legacy plugin integration and compatibility.
- Updated protocol messages to include provided capabilities in initialization.
- Enhanced top-level module imports to include capability-related functions.
2026-03-13 06:16:57 +08:00

516 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""处理器分发模块。
定义 HandlerDispatcher 类,负责将能力调用分发到具体的处理器函数。
支持参数注入、流式执行、错误处理和生命周期回调。
核心职责:
- 根据处理器 ID 查找处理器
- 构建处理器参数(支持类型注解注入)
- 执行处理器并处理结果
- 处理异步生成器流式结果
- 统一的错误处理
参数注入优先级:
1. 按类型注解注入(支持 Optional[Type]
2. 按参数名注入(兼容无类型注解)
3. 从 legacy_args 注入(命令参数等)
支持的注入类型:
- MessageEvent: 消息事件
- Context: 运行时上下文
与旧版对比:
旧版 HandlerExecutor:
- 从 star_handlers_registry 获取处理器
- 直接调用 handler(event, **args)
- 无参数注入支持
- 通过 JSON-RPC notification 发送流式结果
- 错误通过 JSON-RPC error 响应
新版 HandlerDispatcher:
- 从 LoadedHandler 映射获取处理器
- 支持类型注解注入 (MessageEvent, Context)
- 支持参数名注入 (event, ctx, context)
- 支持 legacy_args 注入
- 支持 Optional[Type] 类型
- 支持默认值
- 统一的错误处理和 on_error 回调
处理器签名兼容:
# 旧版签名
def handler(event: AstrMessageEvent) -> str:
return "result"
# 新版签名(类型注入)
async def handler(event: MessageEvent, ctx: Context) -> None:
await event.reply("result")
# 新版签名(名字注入)
async def handler(event, ctx) -> None:
await ctx.platform.send(event.session_id, "result")
# 流式处理器
async def streaming_handler(event: MessageEvent):
yield "chunk 1"
yield "chunk 2"
结果处理:
- PlainTextResult: 调用 event.reply()
- str: 调用 event.reply()
- dict with "text": 调用 event.reply(str(item["text"]))
`HandlerDispatcher` 把运行时收到的 `handler.invoke` 请求转成真实 Python 调用。
它的职责只包括参数注入、legacy 返回值兼容和错误回调;不负责 handler 发现或
远端能力路由。
"""
from __future__ import annotations
import asyncio
import inspect
import typing
from collections.abc import AsyncIterator
from typing import Any, get_type_hints
from ..context import CancelToken, Context
from ..errors import AstrBotError
from ..events import MessageEvent, PlainTextResult
from ..star import Star
from .capability_router import StreamExecution
from .loader import LoadedCapability, LoadedHandler
class HandlerDispatcher:
def __init__(self, *, plugin_id: str, peer, handlers: list[LoadedHandler]) -> None:
self._plugin_id = plugin_id
self._peer = peer
self._handlers = {item.descriptor.id: item for item in handlers}
self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {}
async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]:
handler_id = str(message.input.get("handler_id", ""))
loaded = self._handlers.get(handler_id)
if loaded is None:
raise LookupError(f"handler not found: {handler_id}")
ctx = Context(
peer=self._peer, plugin_id=self._plugin_id, cancel_token=cancel_token
)
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)
# 提取 legacy args 用于兼容旧版 handler 签名
legacy_args = message.input.get("args") or {}
task = asyncio.create_task(self._run_handler(loaded, event, ctx, legacy_args))
self._active[message.id] = (task, cancel_token)
try:
await task
return {}
finally:
self._active.pop(message.id, None)
def _create_reply_handler(self, ctx: Context, event: MessageEvent):
async def reply(text: str) -> None:
try:
await ctx.platform.send(event.session_ref or event.session_id, text)
except TypeError:
send = getattr(self._peer, "send", None)
if not callable(send):
raise
result = send(event.session_id, text)
if inspect.isawaitable(result):
await result
return reply
async def cancel(self, request_id: str) -> None:
active = self._active.get(request_id)
if active is None:
return
task, cancel_token = active
cancel_token.cancel()
task.cancel()
async def _run_handler(
self,
loaded: LoadedHandler,
event: MessageEvent,
ctx: Context,
legacy_args: dict[str, Any] | None = None,
) -> None:
try:
result = loaded.callable(
*self._build_args(loaded.callable, event, ctx, legacy_args)
)
if inspect.isasyncgen(result):
async for item in result:
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, ctx)
except Exception as exc:
await self._handle_error(loaded.owner, exc, event, ctx)
raise
def _build_args(
self,
handler,
event: MessageEvent,
ctx: Context,
legacy_args: dict[str, Any] | None = None,
) -> list[Any]:
"""构建 handler 参数列表。
注入优先级:
1. 按类型注解注入(支持 Optional[Type]
2. 按参数名注入(兼容无类型注解的情况)
3. 从 legacy_args 注入命令参数、regex 捕获组等)
Args:
handler: Handler 可调用对象
event: 消息事件
ctx: 运行时上下文
legacy_args: 旧版参数字典
Returns:
参数列表
"""
from loguru import logger
signature = inspect.signature(handler)
args: list[Any] = []
legacy_args = legacy_args or {}
# 尝试获取类型注解
type_hints: dict[str, Any] = {}
try:
type_hints = get_type_hints(handler)
except Exception:
pass
for parameter in signature.parameters.values():
if parameter.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
):
continue
injected = None
# 1. 优先按类型注解注入
param_type = type_hints.get(parameter.name)
if param_type is not None:
injected = self._inject_by_type(param_type, event, ctx)
# 2. Fallback 按名字注入
if injected is None:
if parameter.name == "event":
injected = event
elif parameter.name in {"ctx", "context"}:
injected = ctx
elif parameter.name in legacy_args:
injected = legacy_args[parameter.name]
# 3. 检查是否有默认值
if injected is None:
if parameter.default is not parameter.empty:
# 有默认值,跳过注入
continue
# 无默认值且无法注入,警告并传 None
logger.warning(
f"Handler '{handler.__name__}': 参数 '{parameter.name}' "
f"无法注入(类型: {param_type or '未知'}),将传入 None"
)
args.append(None)
else:
args.append(injected)
return args
def _inject_by_type(
self, param_type: Any, event: MessageEvent, ctx: Context
) -> Any:
"""根据类型注解注入参数。
支持 Optional[Type] 类型。
Args:
param_type: 参数类型注解
event: 消息事件
ctx: 运行时上下文
Returns:
注入的值,若无法注入则返回 None
"""
# 处理 Optional[Type] 情况
origin = typing.get_origin(param_type)
if origin is typing.Union:
args = typing.get_args(param_type)
non_none_types = [a for a in args if a is not type(None)]
if len(non_none_types) == 1:
param_type = non_none_types[0]
# 注入 MessageEvent 及其子类。旧版 compat 事件类型会通过
# from_message_event() 包装成带便捷方法的对象。
if param_type is MessageEvent:
return event
if isinstance(param_type, type) and issubclass(param_type, MessageEvent):
if isinstance(event, param_type):
return event
factory = getattr(param_type, "from_message_event", None)
if callable(factory):
return factory(event)
return event
# 注入 Context 及其子类
if param_type is Context or (
isinstance(param_type, type) and issubclass(param_type, Context)
):
return ctx
return 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_ref or 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_ref or 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, PlainTextResult):
await event.reply(item.text)
return
if isinstance(item, str):
await event.reply(item)
return
if isinstance(item, dict) and "text" in item:
await event.reply(str(item["text"]))
async def _handle_error(
self,
owner: Any,
exc: Exception,
event: MessageEvent,
ctx: Context,
) -> None:
if hasattr(owner, "on_error") and callable(owner.on_error):
result = owner.on_error(exc, event, ctx)
if inspect.isawaitable(result):
await result
return
await Star().on_error(exc, event, ctx)
class CapabilityDispatcher:
def __init__(
self,
*,
plugin_id: str,
peer,
capabilities: list[LoadedCapability],
) -> None:
self._plugin_id = plugin_id
self._peer = peer
self._capabilities = {item.descriptor.name: item for item in capabilities}
self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {}
async def invoke(
self,
message,
cancel_token: CancelToken,
) -> dict[str, Any] | StreamExecution:
loaded = self._capabilities.get(message.capability)
if loaded is None:
raise LookupError(f"capability not found: {message.capability}")
ctx = Context(
peer=self._peer,
plugin_id=self._plugin_id,
cancel_token=cancel_token,
)
if loaded.legacy_context is not None:
loaded.legacy_context.bind_runtime_context(ctx)
task = asyncio.create_task(
self._run_capability(
loaded,
payload=dict(message.input),
ctx=ctx,
cancel_token=cancel_token,
stream=bool(message.stream),
)
)
self._active[message.id] = (task, cancel_token)
try:
return await task
finally:
self._active.pop(message.id, None)
async def cancel(self, request_id: str) -> None:
active = self._active.get(request_id)
if active is None:
return
task, cancel_token = active
cancel_token.cancel()
task.cancel()
async def _run_capability(
self,
loaded: LoadedCapability,
*,
payload: dict[str, Any],
ctx: Context,
cancel_token: CancelToken,
stream: bool,
) -> dict[str, Any] | StreamExecution:
result = loaded.callable(
*self._build_args(loaded.callable, payload, ctx, cancel_token)
)
if stream:
if inspect.isasyncgen(result):
return StreamExecution(
iterator=self._iterate_generator(result),
finalize=lambda chunks: {"items": chunks},
)
if inspect.isawaitable(result):
result = await result
if isinstance(result, StreamExecution):
return result
raise AstrBotError.protocol_error(
"stream=true 的插件 capability 必须返回 async generator 或 StreamExecution"
)
if inspect.isasyncgen(result):
raise AstrBotError.protocol_error(
"stream=false 的插件 capability 不能返回 async generator"
)
if inspect.isawaitable(result):
result = await result
return self._normalize_output(result)
def _build_args(
self,
handler,
payload: dict[str, Any],
ctx: Context,
cancel_token: CancelToken,
) -> list[Any]:
signature = inspect.signature(handler)
args: list[Any] = []
type_hints: dict[str, Any] = {}
try:
type_hints = get_type_hints(handler)
except Exception:
pass
for parameter in signature.parameters.values():
if parameter.kind not in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
):
continue
injected = None
param_type = type_hints.get(parameter.name)
if param_type is not None:
injected = self._inject_by_type(param_type, payload, ctx, cancel_token)
if injected is None:
if parameter.name in {"ctx", "context"}:
injected = ctx
elif parameter.name in {"payload", "input", "data"}:
injected = payload
elif parameter.name in {"cancel_token", "token"}:
injected = cancel_token
if injected is None:
if parameter.default is not parameter.empty:
continue
args.append(None)
continue
args.append(injected)
return args
def _inject_by_type(
self,
param_type: Any,
payload: dict[str, Any],
ctx: Context,
cancel_token: CancelToken,
) -> Any:
origin = typing.get_origin(param_type)
if origin is typing.Union:
args = typing.get_args(param_type)
non_none_types = [item for item in args if item is not type(None)]
if len(non_none_types) == 1:
param_type = non_none_types[0]
origin = typing.get_origin(param_type)
if param_type is Context or (
isinstance(param_type, type) and issubclass(param_type, Context)
):
return ctx
if param_type is CancelToken or (
isinstance(param_type, type) and issubclass(param_type, CancelToken)
):
return cancel_token
if param_type is dict or origin is dict:
return payload
return None
async def _iterate_generator(
self,
generator: AsyncIterator[Any],
) -> AsyncIterator[dict[str, Any]]:
async for item in generator:
yield self._normalize_chunk(item)
def _normalize_chunk(self, item: Any) -> dict[str, Any]:
output = self._normalize_output(item)
if output:
return output
return {"ok": True}
def _normalize_output(self, result: Any) -> dict[str, Any]:
if result is None:
return {}
if isinstance(result, dict):
return result
model_dump = getattr(result, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
if isinstance(dumped, dict):
return dumped
raise AstrBotError.invalid_input("插件 capability 必须返回 dict 或可序列化对象")