feat: Add NapCat adapter plugin and enhance message handling

- Introduced a built-in NapCat adapter plugin for MVP message forwarding.
- Implemented core functionalities for connecting to NapCat/OneBot v11 WebSocket service.
- Added message serialization capabilities for WebUI chat routes.
- Enhanced the RegisterPluginPayload to include optional adapter declarations.
- Implemented methods for handling external messages and adapter declarations in the PluginRunner.
- Improved the send_service to inherit platform IO route metadata for outgoing messages.
This commit is contained in:
DrSmoothl
2026-03-21 00:18:28 +08:00
parent 75cd50ee0f
commit 85f060621d
14 changed files with 1683 additions and 179 deletions

View File

@@ -3,9 +3,11 @@ Message Gateway 模块
适配器专用,用于将其他平台的消息转换为系统内部的消息格式,并将系统消息转换为其他平台的格式。
"""
from typing import Dict, Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Dict
from src.common.logger import get_logger
from src.platform_io import DeliveryStatus, get_platform_io_manager
from .message_utils import PluginMessageUtils
if TYPE_CHECKING:
@@ -17,25 +19,53 @@ logger = get_logger("plugin_runtime.host.message_gateway")
class MessageGateway:
def __init__(self, component_registry: "ComponentRegistry") -> None:
self._component_registry = component_registry
"""Host 侧消息网关包装器。"""
async def receive_external_message(self, external_message: Dict[str, Any]):
"""
接收外部消息,转换为系统内部格式,并返回转换结果
def __init__(self, component_registry: "ComponentRegistry") -> None:
"""初始化消息网关。
Args:
external_message: 外部消息的字典格式数据
component_registry: 组件注册表。
"""
self._component_registry = component_registry
def build_session_message(self, external_message: Dict[str, Any]) -> "SessionMessage":
"""将标准消息字典转换为 ``SessionMessage``。
Args:
external_message: 外部消息的字典格式数据。
Returns:
转换后的 SessionMessage 对象
SessionMessage: 转换后的内部消息对象
Raises:
ValueError: 消息字典不合法时抛出。
"""
return PluginMessageUtils._build_session_message_from_dict(external_message)
def build_message_dict(self, internal_message: "SessionMessage") -> Dict[str, Any]:
"""将 ``SessionMessage`` 转换为标准消息字典。
Args:
internal_message: 内部消息对象。
Returns:
Dict[str, Any]: 供适配器插件消费的标准消息字典。
"""
return dict(PluginMessageUtils._session_message_to_dict(internal_message))
async def receive_external_message(self, external_message: Dict[str, Any]) -> None:
"""接收外部消息并送入主消息链。
Args:
external_message: 外部消息的字典格式数据。
"""
# 使用递归函数将外部消息字典转换为 SessionMessage
try:
session_message = PluginMessageUtils._build_session_message_from_dict(external_message)
session_message = self.build_session_message(external_message)
except Exception as e:
logger.error(f"转换外部消息失败: {e}")
return
from src.chat.message_receive.bot import chat_bot
await chat_bot.receive_message(session_message)
@@ -48,46 +78,32 @@ class MessageGateway:
enabled_only: bool = True,
save_to_db: bool = True,
) -> bool:
"""
接收系统内部消息,转换为外部格式,并返回转换结果
"""将内部消息通过 Platform IO 发送到外部平台。
Args:
internal_message: 系统内部的 SessionMessage 对象
internal_message: 系统内部的 ``SessionMessage`` 对象
supervisor: 当前持有该消息网关的 Supervisor。
enabled_only: 兼容旧签名的保留参数,当前由 Platform IO 统一裁决。
save_to_db: 发送成功后是否写入数据库。
Returns:
转换是否成功
bool: 是否发送成功
"""
try:
# 将 SessionMessage 转换为字典格式
message_dict = PluginMessageUtils._session_message_to_dict(internal_message)
except Exception as e:
logger.error(f"转换内部消息失败:{e}")
return False
gateway_entry = self._component_registry.get_message_gateways(
internal_message.platform,
enabled_only=enabled_only,
session_id=internal_message.session_id,
)
if not gateway_entry:
logger.warning(f"未找到适配平台 {internal_message.platform} 的消息网关组件,无法发送消息到外部平台")
return False
args = {"platform": internal_message.platform, "message": message_dict}
try:
resp_envelope = await supervisor.invoke_plugin(
"plugin.emit_event", gateway_entry.plugin_id, gateway_entry.name, args
)
logger.debug("信息发送成功")
except Exception as e:
logger.error(f"调用消息网关组件失败:{e}")
del enabled_only
del supervisor
platform_io_manager = get_platform_io_manager()
if not platform_io_manager.is_started:
logger.warning("Platform IO 尚未启动,无法通过适配器链路发送消息")
return False
# 更新为实际id如果组件返回了新的id
actual_message_id = resp_envelope.payload.get("message_id")
try:
actual_message_id = str(actual_message_id)
except Exception:
actual_message_id = None
internal_message.message_id = actual_message_id or internal_message.message_id
route_key = platform_io_manager.build_route_key_from_message(internal_message)
receipt = await platform_io_manager.send_message(internal_message, route_key)
if receipt.status != DeliveryStatus.SENT:
logger.warning(f"通过适配器链路发送消息失败: {receipt.error or receipt.status}")
return False
internal_message.message_id = receipt.external_message_id or internal_message.message_id
if save_to_db:
try:
from src.common.utils.utils_message import MessageUtils

View File

@@ -209,6 +209,9 @@ class PluginMessageUtils:
session_message.is_notify = message_dict.get("is_notify", False)
if not isinstance(session_message.is_notify, bool):
session_message.is_notify = False
session_message.session_id = message_dict.get("session_id", "")
if not isinstance(session_message.session_id, str):
session_message.session_id = ""
session_message.reply_to = message_dict.get("reply_to")
if session_message.reply_to is not None and not isinstance(session_message.reply_to, str):
session_message.reply_to = None

View File

@@ -8,13 +8,20 @@ import sys
from src.common.logger import get_logger
from src.config.config import global_config
from src.platform_io import DriverKind, InboundMessageEnvelope, RouteBinding, RouteKey, get_platform_io_manager
from src.platform_io.drivers import PluginPlatformDriver
from src.platform_io.route_key_factory import RouteKeyFactory
from src.platform_io.routing import RouteBindingConflictError
from src.plugin_runtime import ENV_HOST_VERSION, ENV_IPC_ADDRESS, ENV_PLUGIN_DIRS, ENV_SESSION_TOKEN
from src.plugin_runtime.protocol.envelope import (
AdapterDeclarationPayload,
BootstrapPluginPayload,
ConfigUpdatedPayload,
Envelope,
HealthPayload,
PROTOCOL_VERSION,
ReceiveExternalMessagePayload,
ReceiveExternalMessageResultPayload,
RegisterPluginPayload,
ReloadPluginResultPayload,
RunnerReadyPayload,
@@ -86,6 +93,7 @@ class PluginRunnerSupervisor:
self._runner_process: Optional[asyncio.subprocess.Process] = None
self._registered_plugins: Dict[str, RegisterPluginPayload] = {}
self._registered_adapters: Dict[str, AdapterDeclarationPayload] = {}
self._runner_ready_events: asyncio.Event = asyncio.Event()
self._runner_ready_payloads: RunnerReadyPayload = RunnerReadyPayload()
self._health_task: Optional[asyncio.Task[None]] = None
@@ -257,6 +265,32 @@ class PluginRunnerSupervisor:
timeout_ms,
)
async def invoke_adapter(
self,
plugin_id: str,
method_name: str,
args: Optional[Dict[str, Any]] = None,
timeout_ms: int = 30000,
) -> Envelope:
"""调用适配器插件的专用方法。
Args:
plugin_id: 目标适配器插件 ID。
method_name: 要调用的插件方法名,例如 ``send_to_platform``。
args: 传递给插件方法的关键字参数。
timeout_ms: RPC 超时时间,单位毫秒。
Returns:
Envelope: Runner 返回的响应信封。
"""
return await self.invoke_plugin(
method="plugin.invoke_adapter",
plugin_id=plugin_id,
component_name=method_name,
args=args,
timeout_ms=timeout_ms,
)
async def reload_plugin(self, plugin_id: str, reason: str = "manual") -> bool:
"""按插件 ID 触发精确重载。
@@ -384,6 +418,7 @@ class PluginRunnerSupervisor:
def _register_internal_methods(self) -> None:
"""注册 Host 侧内部 RPC 方法。"""
self._rpc_server.register_method("cap.call", self._capability_service.handle_capability_request)
self._rpc_server.register_method("host.receive_external_message", self._handle_receive_external_message)
self._rpc_server.register_method("plugin.bootstrap", self._handle_bootstrap_plugin)
self._rpc_server.register_method("plugin.register_components", self._handle_register_plugin)
self._rpc_server.register_method("plugin.register_plugin", self._handle_register_plugin)
@@ -427,6 +462,17 @@ class PluginRunnerSupervisor:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
self._component_registry.remove_components_by_plugin(payload.plugin_id)
if payload.plugin_id in self._registered_adapters:
await self._unregister_adapter_driver(payload.plugin_id)
try:
if payload.adapter is not None:
await self._register_adapter_driver(payload.plugin_id, payload.adapter)
except RouteBindingConflictError as exc:
return envelope.make_error_response(ErrorCode.E_METHOD_NOT_ALLOWED.value, str(exc))
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(exc))
registered_count = self._component_registry.register_plugin_components(
payload.plugin_id,
[component.model_dump() for component in payload.components],
@@ -438,6 +484,7 @@ class PluginRunnerSupervisor:
"accepted": True,
"plugin_id": payload.plugin_id,
"registered_components": registered_count,
"adapter_registered": payload.adapter is not None,
}
)
@@ -458,6 +505,7 @@ class PluginRunnerSupervisor:
removed_components = self._component_registry.remove_components_by_plugin(payload.plugin_id)
self._authorization.revoke_permission_token(payload.plugin_id)
removed_registration = self._registered_plugins.pop(payload.plugin_id, None) is not None
await self._unregister_adapter_driver(payload.plugin_id)
return envelope.make_response(
payload={
@@ -469,6 +517,221 @@ class PluginRunnerSupervisor:
}
)
@staticmethod
def _build_adapter_driver_id(plugin_id: str) -> str:
"""构造适配器驱动 ID。
Args:
plugin_id: 适配器插件 ID。
Returns:
str: 对应 Platform IO 中的驱动 ID。
"""
return f"adapter:{plugin_id}"
async def _register_adapter_driver(self, plugin_id: str, adapter: AdapterDeclarationPayload) -> None:
"""将适配器插件注册到 Platform IO。
Args:
plugin_id: 适配器插件 ID。
adapter: 经过校验的适配器声明。
Raises:
ValueError: 适配器路由冲突或驱动注册失败时抛出。
"""
await self._unregister_adapter_driver(plugin_id)
platform_io_manager = get_platform_io_manager()
driver = PluginPlatformDriver(
driver_id=self._build_adapter_driver_id(plugin_id),
platform=adapter.platform,
account_id=adapter.account_id or None,
scope=adapter.scope or None,
plugin_id=plugin_id,
send_method=adapter.send_method,
supervisor=self,
metadata={
"protocol": adapter.protocol,
**adapter.metadata,
},
)
binding = RouteBinding(
route_key=driver.descriptor.route_key,
driver_id=driver.driver_id,
driver_kind=DriverKind.PLUGIN,
metadata={
"plugin_id": plugin_id,
"protocol": adapter.protocol,
},
)
try:
if platform_io_manager.is_started:
await platform_io_manager.add_driver(driver)
else:
platform_io_manager.register_driver(driver)
platform_io_manager.bind_route(binding)
except Exception:
with contextlib.suppress(Exception):
if platform_io_manager.is_started:
await platform_io_manager.remove_driver(driver.driver_id)
else:
platform_io_manager.unregister_driver(driver.driver_id)
raise
self._registered_adapters[plugin_id] = adapter
async def _unregister_adapter_driver(self, plugin_id: str) -> None:
"""从 Platform IO 注销一个适配器驱动。
Args:
plugin_id: 适配器插件 ID。
"""
platform_io_manager = get_platform_io_manager()
driver_id = self._build_adapter_driver_id(plugin_id)
with contextlib.suppress(Exception):
if platform_io_manager.is_started:
await platform_io_manager.remove_driver(driver_id)
else:
platform_io_manager.unregister_driver(driver_id)
self._registered_adapters.pop(plugin_id, None)
async def _unregister_all_adapter_drivers(self) -> None:
"""注销当前 Supervisor 管理的全部适配器驱动。"""
plugin_ids = list(self._registered_adapters.keys())
for plugin_id in plugin_ids:
await self._unregister_adapter_driver(plugin_id)
@staticmethod
def _attach_inbound_route_metadata(
session_message: "SessionMessage",
route_key: RouteKey,
route_metadata: Dict[str, Any],
) -> None:
"""将入站路由信息写回消息的 ``additional_config``。
Args:
session_message: 已构造好的内部消息对象。
route_key: Host 为该消息解析出的标准路由键。
route_metadata: 适配器通过 RPC 补充的原始路由辅助元数据。
"""
additional_config = session_message.message_info.additional_config
if not isinstance(additional_config, dict):
additional_config = {}
session_message.message_info.additional_config = additional_config
for key, value in route_metadata.items():
if value is None:
continue
normalized_value = str(value).strip()
if normalized_value:
additional_config[key] = value
if route_key.account_id:
additional_config.setdefault("platform_io_account_id", route_key.account_id)
if route_key.scope:
additional_config.setdefault("platform_io_scope", route_key.scope)
def _build_inbound_route_key(
self,
adapter: AdapterDeclarationPayload,
message: Dict[str, Any],
route_metadata: Dict[str, Any],
) -> RouteKey:
"""为适配器入站消息构造归一路由键。
Args:
adapter: 当前适配器声明。
message: 标准消息字典。
route_metadata: 插件补充的路由辅助元数据。
Returns:
RouteKey: 供 Platform IO 使用的规范化路由键。
Raises:
ValueError: 消息平台字段与适配器平台声明不一致时抛出。
"""
message_platform = str(message.get("platform") or adapter.platform).strip()
if message_platform != adapter.platform:
raise ValueError(
f"外部消息平台 {message_platform} 与适配器 {adapter.platform} 不一致"
)
try:
route_key = RouteKeyFactory.from_message_dict(message)
except Exception:
route_key = RouteKey(platform=message_platform)
route_account_id, route_scope = RouteKeyFactory.extract_components(route_metadata)
account_id = route_key.account_id or route_account_id or adapter.account_id or None
scope = route_key.scope or route_scope or adapter.scope or None
return RouteKey(
platform=message_platform,
account_id=account_id,
scope=scope,
)
async def _handle_receive_external_message(self, envelope: Envelope) -> Envelope:
"""处理适配器插件上报的外部入站消息。
Args:
envelope: RPC 请求信封。
Returns:
Envelope: 注入结果响应。
"""
try:
payload = ReceiveExternalMessagePayload.model_validate(envelope.payload)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
adapter = self._registered_adapters.get(envelope.plugin_id)
if adapter is None:
return envelope.make_error_response(
ErrorCode.E_METHOD_NOT_ALLOWED.value,
f"插件 {envelope.plugin_id} 未声明为适配器,不能注入外部消息",
)
try:
route_key = self._build_inbound_route_key(
adapter=adapter,
message=payload.message,
route_metadata=payload.route_metadata,
)
session_message = self._message_gateway.build_session_message(payload.message)
self._attach_inbound_route_metadata(session_message, route_key, payload.route_metadata)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
platform_io_manager = get_platform_io_manager()
accepted = await platform_io_manager.accept_inbound(
InboundMessageEnvelope(
route_key=route_key,
driver_id=self._build_adapter_driver_id(envelope.plugin_id),
driver_kind=DriverKind.PLUGIN,
external_message_id=payload.external_message_id or str(payload.message.get("message_id") or "") or None,
dedupe_key=payload.dedupe_key or None,
session_message=session_message,
payload=payload.message,
metadata={
"plugin_id": envelope.plugin_id,
"protocol": adapter.protocol,
**payload.route_metadata,
},
)
)
response = ReceiveExternalMessageResultPayload(
accepted=accepted,
route_key={
"platform": route_key.platform,
"account_id": route_key.account_id,
"scope": route_key.scope,
},
)
return envelope.make_response(payload=response.model_dump())
async def _handle_runner_ready(self, envelope: Envelope) -> Envelope:
"""处理 Runner 就绪通知。
@@ -595,6 +858,7 @@ class PluginRunnerSupervisor:
await self._stderr_drain_task
self._stderr_drain_task = None
await self._unregister_all_adapter_drivers()
self._clear_runner_state()
async def _health_check_loop(self) -> None:
@@ -671,6 +935,7 @@ class PluginRunnerSupervisor:
self._authorization.clear()
self._component_registry.clear()
self._registered_plugins.clear()
self._registered_adapters.clear()
self._runner_ready_events = asyncio.Event()
self._runner_ready_payloads = RunnerReadyPayload()

View File

@@ -12,11 +12,13 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Ite
import asyncio
import json
import tomlkit
from src.common.logger import get_logger
from src.config.config import global_config
from src.config.file_watcher import FileChange, FileWatcher
from src.platform_io import DeliveryReceipt, InboundMessageEnvelope, get_platform_io_manager
from src.plugin_runtime.capabilities import (
RuntimeComponentCapabilityMixin,
RuntimeCoreCapabilityMixin,
@@ -57,6 +59,7 @@ class PluginRuntimeManager(
"""
def __init__(self) -> None:
"""初始化插件运行时管理器。"""
from src.plugin_runtime.host.supervisor import PluginSupervisor
self._builtin_supervisor: Optional[PluginSupervisor] = None
@@ -66,6 +69,22 @@ class PluginRuntimeManager(
self._plugin_source_watcher_subscription_id: Optional[str] = None
self._plugin_config_watcher_subscriptions: Dict[str, Tuple[Path, str]] = {}
async def _dispatch_platform_inbound(self, envelope: InboundMessageEnvelope) -> None:
"""接收 Platform IO 审核后的入站消息并送入主消息链。
Args:
envelope: Platform IO 产出的入站封装。
"""
session_message = envelope.session_message
if session_message is None and envelope.payload is not None:
session_message = PluginMessageUtils._build_session_message_from_dict(dict(envelope.payload))
if session_message is None:
raise ValueError("Platform IO 入站封装缺少可用的 SessionMessage 或 payload")
from src.chat.message_receive.bot import chat_bot
await chat_bot.receive_message(session_message)
# ─── 插件目录 ─────────────────────────────────────────────
@staticmethod
@@ -110,6 +129,8 @@ class PluginRuntimeManager(
logger.info("未找到任何插件目录,跳过插件运行时启动")
return
platform_io_manager = get_platform_io_manager()
# 从配置读取自定义 IPC socket 路径(留空则自动生成)
socket_path_base = _cfg.ipc_socket_path or None
@@ -134,6 +155,9 @@ class PluginRuntimeManager(
started_supervisors: List[PluginSupervisor] = []
try:
platform_io_manager.set_inbound_dispatcher(self._dispatch_platform_inbound)
await platform_io_manager.start()
if self._builtin_supervisor:
await self._builtin_supervisor.start()
started_supervisors.append(self._builtin_supervisor)
@@ -147,6 +171,11 @@ class PluginRuntimeManager(
logger.error(f"插件运行时启动失败: {e}", exc_info=True)
await self._stop_plugin_file_watcher()
await asyncio.gather(*(sv.stop() for sv in started_supervisors), return_exceptions=True)
platform_io_manager.clear_inbound_dispatcher()
try:
await platform_io_manager.stop()
except Exception as platform_io_exc:
logger.warning(f"Platform IO 停止失败: {platform_io_exc}")
self._started = False
self._builtin_supervisor = None
self._third_party_supervisor = None
@@ -156,6 +185,7 @@ class PluginRuntimeManager(
if not self._started:
return
platform_io_manager = get_platform_io_manager()
await self._stop_plugin_file_watcher()
coroutines: List[Coroutine[Any, Any, None]] = []
@@ -164,11 +194,23 @@ class PluginRuntimeManager(
if self._third_party_supervisor:
coroutines.append(self._third_party_supervisor.stop())
stop_errors: List[str] = []
try:
await asyncio.gather(*coroutines, return_exceptions=True)
logger.info("插件运行时已停止")
except Exception as e:
logger.error(f"插件运行时停止失败: {e}", exc_info=True)
results = await asyncio.gather(*coroutines, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
stop_errors.append(str(result))
platform_io_manager.clear_inbound_dispatcher()
try:
await platform_io_manager.stop()
except Exception as exc:
stop_errors.append(f"Platform IO: {exc}")
if stop_errors:
logger.error(f"插件运行时停止过程中存在错误: {'; '.join(stop_errors)}")
else:
logger.info("插件运行时已停止")
finally:
self._started = False
self._builtin_supervisor = None
@@ -176,6 +218,7 @@ class PluginRuntimeManager(
@property
def is_running(self) -> bool:
"""返回插件运行时是否处于启动状态。"""
return self._started
@property
@@ -303,6 +346,37 @@ class PluginRuntimeManager(
timeout_ms=timeout_ms,
)
async def try_send_message_via_platform_io(
self,
message: "SessionMessage",
) -> Optional[DeliveryReceipt]:
"""尝试通过 Platform IO 中间层发送消息。
Args:
message: 待发送的内部会话消息。
Returns:
Optional[DeliveryReceipt]: 若当前消息存在 active 路由,则返回实际发送
结果;若没有可用路由或 Platform IO 尚未启动,则返回 ``None``。
"""
if not self._started:
return None
platform_io_manager = get_platform_io_manager()
if not platform_io_manager.is_started:
return None
try:
route_key = platform_io_manager.build_route_key_from_message(message)
except Exception as exc:
logger.warning(f"根据消息构造 Platform IO 路由键失败: {exc}")
return None
if platform_io_manager.resolve_driver(route_key) is None:
return None
return await platform_io_manager.send_message(message, route_key)
def _get_supervisors_for_plugin(self, plugin_id: str) -> List["PluginSupervisor"]:
"""返回当前持有指定插件的所有 Supervisor。
@@ -426,6 +500,11 @@ class PluginRuntimeManager(
"""为指定插件生成配置文件变更回调。"""
async def _callback(changes: Sequence[FileChange]) -> None:
"""将 watcher 事件转发到指定插件的配置处理逻辑。
Args:
changes: 当前批次收集到的文件变更列表。
"""
await self._handle_plugin_config_changes(plugin_id, changes)
return _callback
@@ -542,6 +621,11 @@ class PluginRuntimeManager(
# ─── 能力实现注册 ──────────────────────────────────────────
def _register_capability_impls(self, supervisor: "PluginSupervisor") -> None:
"""向指定 Supervisor 注册主程序能力实现。
Args:
supervisor: 需要注册能力实现的目标 Supervisor。
"""
register_capability_impls(self, supervisor)

View File

@@ -7,11 +7,11 @@
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
import logging as stdlib_logging
import time
from pydantic import BaseModel, Field
# ====== 协议常量 ======
PROTOCOL_VERSION = "1.0.0"
@@ -156,6 +156,8 @@ class RegisterPluginPayload(BaseModel):
"""插件版本"""
components: List[ComponentDeclaration] = Field(default_factory=list, description="组件列表")
"""组件列表"""
adapter: Optional["AdapterDeclarationPayload"] = Field(default=None, description="可选的适配器声明")
"""可选的适配器声明"""
capabilities_required: List[str] = Field(default_factory=list, description="所需能力列表")
"""所需能力列表"""
@@ -285,6 +287,48 @@ class ReloadPluginResultPayload(BaseModel):
"""重载失败的插件及原因"""
class AdapterDeclarationPayload(BaseModel):
"""适配器插件声明载荷。"""
platform: str = Field(description="适配器负责的平台名称,例如 qq")
"""适配器负责的平台名称,例如 qq"""
protocol: str = Field(default="", description="接入协议或实现名称,例如 napcat")
"""接入协议或实现名称,例如 napcat"""
account_id: str = Field(default="", description="可选的账号 ID 或 self_id")
"""可选的账号 ID 或 self_id"""
scope: str = Field(default="", description="可选的路由作用域")
"""可选的路由作用域"""
send_method: str = Field(default="send_to_platform", description="Host 出站调用的插件方法名")
"""Host 出站调用的插件方法名"""
metadata: Dict[str, Any] = Field(default_factory=dict, description="适配器附加元数据")
"""适配器附加元数据"""
class ReceiveExternalMessagePayload(BaseModel):
"""适配器插件向 Host 注入外部消息的请求载荷。"""
message: Dict[str, Any] = Field(description="符合 MessageDict 结构的标准消息字典")
"""符合 MessageDict 结构的标准消息字典"""
route_metadata: Dict[str, Any] = Field(default_factory=dict, description="可选的路由辅助元数据")
"""可选的路由辅助元数据"""
external_message_id: str = Field(default="", description="可选的外部平台消息 ID")
"""可选的外部平台消息 ID"""
dedupe_key: str = Field(default="", description="可选的显式去重键")
"""可选的显式去重键"""
class ReceiveExternalMessageResultPayload(BaseModel):
"""外部消息注入结果载荷。"""
accepted: bool = Field(description="Host 是否接受了本次消息注入")
"""Host 是否接受了本次消息注入"""
route_key: Dict[str, Any] = Field(default_factory=dict, description="本次消息使用的归一路由键")
"""本次消息使用的归一路由键"""
RegisterPluginPayload.model_rebuild()
# ====== 日志传输 ======

View File

@@ -9,9 +9,8 @@
6. 转发插件的能力调用到 Host
"""
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
import asyncio
import contextlib
@@ -26,6 +25,7 @@ import tomllib
from src.common.logger import get_console_handler, get_logger, initialize_logging
from src.plugin_runtime import ENV_HOST_VERSION, ENV_IPC_ADDRESS, ENV_PLUGIN_DIRS, ENV_SESSION_TOKEN
from src.plugin_runtime.protocol.envelope import (
AdapterDeclarationPayload,
BootstrapPluginPayload,
ComponentDeclaration,
Envelope,
@@ -227,7 +227,7 @@ class PluginRunner:
plugin_id: str = "",
payload: Optional[Dict[str, Any]] = None,
) -> Any:
"""桥接 PluginContext.call_capability → RPCClient.send_request。
"""桥接 PluginContext 的原始 RPC 调用到 Host。
无论调用方传入何种 plugin_id实际发往 Host 的 plugin_id
始终绑定为当前插件实例,避免伪造其他插件身份申请能力。
@@ -237,17 +237,13 @@ class PluginRunner:
f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC已强制绑定回自身身份"
)
resp = await rpc_client.send_request(
method="cap.call",
method=method,
plugin_id=bound_plugin_id,
payload={
"capability": method,
"args": payload or {},
},
payload=payload or {},
)
# 从响应信封中提取业务结果
if resp.error:
raise RuntimeError(resp.error.get("message", "能力调用失败"))
return resp.payload.get("result")
return resp.payload
ctx = PluginContext(plugin_id=plugin_id, rpc_call=_rpc_call)
cast(_ContextAwarePlugin, instance)._set_context(ctx)
@@ -286,6 +282,7 @@ class PluginRunner:
self._rpc_client.register_method("plugin.invoke_command", self._handle_invoke)
self._rpc_client.register_method("plugin.invoke_action", self._handle_invoke)
self._rpc_client.register_method("plugin.invoke_tool", self._handle_invoke)
self._rpc_client.register_method("plugin.invoke_adapter", self._handle_invoke)
self._rpc_client.register_method("plugin.emit_event", self._handle_event_invoke)
self._rpc_client.register_method("plugin.invoke_hook", self._handle_hook_invoke)
self._rpc_client.register_method("plugin.invoke_workflow_step", self._handle_workflow_step)
@@ -306,12 +303,14 @@ class PluginRunner:
)
try:
await self._rpc_client.send_request(
response = await self._rpc_client.send_request(
"plugin.bootstrap",
plugin_id=meta.plugin_id,
payload=payload.model_dump(),
timeout_ms=10000,
)
if response.error:
raise RuntimeError(response.error.get("message", "插件 bootstrap 失败"))
return True
except Exception as e:
logger.error(f"插件 {meta.plugin_id} bootstrap 失败: {e}")
@@ -321,6 +320,29 @@ class PluginRunner:
"""撤销 bootstrap 期间为插件签发的能力令牌。"""
await self._bootstrap_plugin(meta, capabilities_required=[])
def _collect_adapter_declaration(self, meta: PluginMeta) -> Optional[AdapterDeclarationPayload]:
"""从插件实例中提取适配器声明。
Args:
meta: 待提取声明的插件元数据。
Returns:
Optional[AdapterDeclarationPayload]: 若插件声明了适配器角色,则返回
经过校验的适配器声明;否则返回 ``None``。
Raises:
ValueError: 插件导出的适配器声明结构非法时抛出。
"""
instance = meta.instance
if not hasattr(instance, "get_adapter_info"):
return None
adapter_info = instance.get_adapter_info()
if adapter_info is None:
return None
return AdapterDeclarationPayload.model_validate(adapter_info)
async def _register_plugin(self, meta: PluginMeta) -> bool:
"""向 Host 注册单个插件。
@@ -346,20 +368,29 @@ class PluginRunner:
for comp_info in instance.get_components()
)
try:
adapter = self._collect_adapter_declaration(meta)
except Exception as exc:
logger.error(f"插件 {meta.plugin_id} 适配器声明非法: {exc}", exc_info=True)
return False
reg_payload = RegisterPluginPayload(
plugin_id=meta.plugin_id,
plugin_version=meta.version,
components=components,
adapter=adapter,
capabilities_required=meta.capabilities_required,
)
try:
_resp = await self._rpc_client.send_request(
response = await self._rpc_client.send_request(
"plugin.register_components",
plugin_id=meta.plugin_id,
payload=reg_payload.model_dump(),
timeout_ms=10000,
)
if response.error:
raise RuntimeError(response.error.get("message", "插件注册失败"))
logger.info(f"插件 {meta.plugin_id} 注册完成")
return True
except Exception as e: