From b438af3ba8bbb1ee022d9b80fca7cda760fb7888 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 10:29:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel-sdk):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=9A=84=E6=B8=A0=E9=81=93SDK=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本提交新增了全渠道SDK核心模块: 1. 异步锁、目标解析、动作调度等基础工具 2. 消息动作注册与统一调度系统 3. 测试套件与契约测试框架 4. 完整的目标解析流水线与工具函数 5. 资源依赖注入与生命周期管理 --- backend/package/yuxi/channel/sdk/__init__.py | 485 ++++++++++++++++++ .../yuxi/channel/sdk/actions/__init__.py | 27 + .../yuxi/channel/sdk/actions/adapter.py | 116 +++++ .../yuxi/channel/sdk/actions/discovery.py | 68 +++ .../yuxi/channel/sdk/actions/dispatch.py | 83 +++ .../package/yuxi/channel/sdk/actions/names.py | 74 +++ .../package/yuxi/channel/sdk/async_lock.py | 17 + .../package/yuxi/channel/sdk/async_utils.py | 67 +++ .../yuxi/channel/sdk/channel_lifecycle.py | 83 +++ .../package/yuxi/channel/sdk/dependencies.py | 54 ++ .../yuxi/channel/sdk/targets/__init__.py | 26 + .../yuxi/channel/sdk/targets/display.py | 35 ++ .../yuxi/channel/sdk/targets/envelope.py | 62 +++ .../yuxi/channel/sdk/targets/extractors.py | 79 +++ .../yuxi/channel/sdk/targets/normalizer.py | 50 ++ .../yuxi/channel/sdk/targets/pipeline.py | 182 +++++++ .../package/yuxi/channel/sdk/targets/types.py | 35 ++ .../package/yuxi/channel/sdk/test_utils.py | 27 + .../yuxi/channel/sdk/testing/__init__.py | 42 ++ .../yuxi/channel/sdk/testing/contracts.py | 170 ++++++ .../yuxi/channel/sdk/testing/fixtures.py | 49 ++ .../yuxi/channel/sdk/testing/helpers.py | 25 + .../package/yuxi/channel/sdk/testing/mocks.py | 131 +++++ .../yuxi/channel/sdk/testing/testkit.py | 33 ++ 24 files changed, 2020 insertions(+) create mode 100644 backend/package/yuxi/channel/sdk/__init__.py create mode 100644 backend/package/yuxi/channel/sdk/actions/__init__.py create mode 100644 backend/package/yuxi/channel/sdk/actions/adapter.py create mode 100644 backend/package/yuxi/channel/sdk/actions/discovery.py create mode 100644 backend/package/yuxi/channel/sdk/actions/dispatch.py create mode 100644 backend/package/yuxi/channel/sdk/actions/names.py create mode 100644 backend/package/yuxi/channel/sdk/async_lock.py create mode 100644 backend/package/yuxi/channel/sdk/async_utils.py create mode 100644 backend/package/yuxi/channel/sdk/channel_lifecycle.py create mode 100644 backend/package/yuxi/channel/sdk/dependencies.py create mode 100644 backend/package/yuxi/channel/sdk/targets/__init__.py create mode 100644 backend/package/yuxi/channel/sdk/targets/display.py create mode 100644 backend/package/yuxi/channel/sdk/targets/envelope.py create mode 100644 backend/package/yuxi/channel/sdk/targets/extractors.py create mode 100644 backend/package/yuxi/channel/sdk/targets/normalizer.py create mode 100644 backend/package/yuxi/channel/sdk/targets/pipeline.py create mode 100644 backend/package/yuxi/channel/sdk/targets/types.py create mode 100644 backend/package/yuxi/channel/sdk/test_utils.py create mode 100644 backend/package/yuxi/channel/sdk/testing/__init__.py create mode 100644 backend/package/yuxi/channel/sdk/testing/contracts.py create mode 100644 backend/package/yuxi/channel/sdk/testing/fixtures.py create mode 100644 backend/package/yuxi/channel/sdk/testing/helpers.py create mode 100644 backend/package/yuxi/channel/sdk/testing/mocks.py create mode 100644 backend/package/yuxi/channel/sdk/testing/testkit.py diff --git a/backend/package/yuxi/channel/sdk/__init__.py b/backend/package/yuxi/channel/sdk/__init__.py new file mode 100644 index 00000000..bdf7fda1 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/__init__.py @@ -0,0 +1,485 @@ +from yuxi import __version__, get_version +from yuxi.channel.approval import ApprovalEngine +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.context import ( + ChannelContext, + ChannelRuntimeContexts, + ContextVisibilityMode, + ConversationBinding, + SupplementalContext, + task_scoped_contexts, +) +from yuxi.channel.doctor import ChannelDoctorRunner, doctor_runner, get_doctor_runner, reset_doctor_runner, set_doctor_runner +from yuxi.channel.doctor.models import ( + CheckStatus, + ConnectivityCheckResult, + CredentialCheckResult, + DiagnosisResult, + DiagnosisWarning, + DoctorOptions, + DoctorRepairMode, + LegacyConfigRule, + PermissionCheckResult, + RepairResult, + RepairStep, + Severity, +) +from yuxi.channel.gateway.client import GatewayClient, GatewayClientError +from yuxi.channel.gateway.lanes import ChannelLane, LaneManager +from yuxi.channel.gateway.channel_plugin import GatewayChannelPlugin +from yuxi.channel.gateway.protocol import ( + DeliveryMode, + FrameType, + GatewayErrorCode, + GatewayRpcMethod, +) +from yuxi.channel.gateway.routes import WebhookRegistry, build_webhook_path +from yuxi.channel.gateway.validation import ( + DiagnoseParams, + ProbeParams, + RepairParams, + SendMessageParams, + StartAccountParams, + StopAccountParams, + validate_params, +) +from yuxi.channel.hooks.lifecycle import HookCallback, HookEvent, HookRegistration, LifecycleHookRegistry +from yuxi.channel.runtime.manager import gateway, get_gateway, reset_gateway, set_gateway +from yuxi.channel.message.durable_send import ( + DurableMessageReceipt, + DurableStrategy, + MessageSendContext, + MessageSendState, +) +from yuxi.channel.message.models import ( + ChunkType, + DispatchResult, + GroupContext, + MentionSource, + MessageReceipt, + MessageType, + PeerInfo, + ReplyPayload, + ReplyStage, + StreamingChunk, + UnifiedMessage, +) +from yuxi.channel.protocols import ( + AgentPromptContext, + AgentPromptProtocol, + AgentTool, + AgentToolParam, + AgentToolProtocol, + AllowlistManagementProtocol, + ApprovalAction, + ApprovalDecision, + ApprovalPluginProtocol, + ApprovalProtocol, + ApprovalRequest, + ApprovalResult, + AuthProtocol, + BasePluginProtocol, + BoundReplyPayload, + CapabilitiesProtocol, + ChannelAccountSnapshot, + ChannelStatusIssue, + ChannelCommand, + CommandsProtocol, + ConfigPluginProtocol, + ConfigProtocol, + ConfigSchemaProtocol, + DefaultsProtocol, + DirectoryGroup, + DirectoryPeer, + DirectoryProtocol, + DirectorySearchParams, + DirectorySearchResult, + DoctorProtocol, + ElevatedProtocol, + FullMessagingPluginProtocol, + GatewayPluginProtocol, + GatewayProtocol, + GroupsProtocol, + HeartbeatProtocol, + InboundMessageHook, + LifecycleProtocol, + MentionsProtocol, + MessageActionCapability, + MessageActionProtocol, + MessageSendingHook, + MessagingProtocol, + MetaProtocol, + OutboundPluginProtocol, + OutboundProtocol, + PairingProtocol, + PollingPluginProtocol, + ReloadProtocol, + ReplyTransport, + ResolverProtocol, + ResolveTarget, + SecurityProtocol, + SendLifecycleHook, + SessionPluginProtocol, + SessionResolution, + SetupProtocol, + SetupWizardProtocol, + SetupWizardStep, + StatusProtocol, + StreamingProtocol, + ThreadInfo, + ThreadingProtocol, +) +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.channel.routing.models import PeerKind +from yuxi.channel.routing.session_key import SessionKeyBuilder +from yuxi.channel.sdk.actions import ( + MESSAGE_ACTION_GROUPS, + MESSAGE_ACTIONS_ATTACHMENT, + MESSAGE_ACTIONS_BASIC, + MESSAGE_ACTIONS_GROUP, + MESSAGE_ACTIONS_POLL, + MESSAGE_ACTIONS_THREAD, + ActionDescriptor, + ActionHandler, + MessageAction, + MessageActionRegistry, +) +from yuxi.channel.sdk.actions.dispatch import ( + DispatchContext, + dispatch_message_action, +) +from yuxi.channel.sdk.actions.discovery import ( + build_unified_message_tool_schema, + get_channel_actions_schema, + list_all_actions, + list_channel_actions, +) +from yuxi.channel.sdk.async_lock import create_async_lock +from yuxi.channel.sdk.async_utils import KeyedAsyncQueue, KeyedAsyncQueueHooks, enqueue_keyed_task +from yuxi.channel.sdk.channel_lifecycle import ( + keep_http_server_task_alive, + run_passive_account_lifecycle, + wait_until_abort, +) +from yuxi.channel.sdk.dependencies import PluginResourceProvider, get_resources, inject_resources, reset_resources +from yuxi.channel.sdk.test_utils import create_test_resources, reset_test_resources +from yuxi.channel.sdk.testing import ContractTestBase, FixtureFactory, create_mock_channel_plugin +from yuxi.channel.security.allowlist import ( + AllowlistChecker, + AllowlistCheckResult, + AllowlistHitReason, + AllowlistStore, + DmPolicyMode, + GroupPolicyMode, + InMemoryAllowlistStore, + PostgresAllowlistStore, + create_allowlist_checker, + get_allowlist_checker, + reset_allowlist_checker, + set_allowlist_checker, +) +from yuxi.channel.security.identity_link import IdentityLinkResolver +from yuxi.channel.security.pairing import ( + InMemoryPairingStore, + PairingManager, + PairingStore, + PostgresPairingStore, +) +from yuxi.channel.session import SessionRecorder, SessionOpResult +from yuxi.channel.streaming.block_chunker import ( + BlockChunker, + EmbeddedBlockChunker, + StreamingBlock, +) +from yuxi.channel.streaming.draft_stream import ( + DraftStreamLoop, + FinalizableDraftStreamControls, + get_tool_status_label, +) +from yuxi.channel.streaming.models import ( + DraftStreamSession, + DraftStreamState, + StreamingConfig, +) +from yuxi.channel.streaming.strategy_selector import ( + StreamingStrategy, + StreamingStrategySelector, +) +from yuxi.channel.utils.lazy import LazyClass, LazyModule, run_sync, run_sync_partial +from yuxi.channel.security.html_guard import sanitize_text_content_preserve_entities, sanitize_url +from yuxi.channel.render.parser import parse_markdown +from yuxi.channel.render.ir import ( + BlockQuote, + Bold, + BoldItalic, + CodeBlock, + Document, + Heading, + Image, + InlineCode, + IrNode, + Italic, + LineBreak, + Link, + ListItem, + OrderedList, + Paragraph, + Strikethrough, + Table, + Text, + ThematicBreak, + UnorderedList, +) + +__all__ = [ + # version + "__version__", + "get_version", + # dependencies + "PluginResourceProvider", + "inject_resources", + "get_resources", + "reset_resources", + # async utils + "KeyedAsyncQueue", + "KeyedAsyncQueueHooks", + "enqueue_keyed_task", + # async lock + "create_async_lock", + # channel lifecycle + "wait_until_abort", + "run_passive_account_lifecycle", + "keep_http_server_task_alive", + # test utils + "create_test_resources", + "reset_test_resources", + "ContractTestBase", + "FixtureFactory", + "create_mock_channel_plugin", + # core + "ChannelPluginRegistry", + "ChannelCapabilities", + "ChannelContext", + "ChannelRuntimeContexts", + "ContextVisibilityMode", + "ConversationBinding", + "SupplementalContext", + "task_scoped_contexts", + # approval + "ApprovalEngine", + "ApprovalAction", + "ApprovalDecision", + "ApprovalProtocol", + "ApprovalRequest", + "ApprovalResult", + # commands + "ChannelCommand", + "CommandsProtocol", + # resolver + "ResolveTarget", + "ResolverProtocol", + # gateway + "ChannelLane", + "DeliveryMode", + "FrameType", + "GatewayErrorCode", + "GatewayClient", + "GatewayClientError", + "GatewayChannelPlugin", + "GatewayRpcMethod", + "LaneManager", + "WebhookRegistry", + "build_webhook_path", + # validation + "StartAccountParams", + "StopAccountParams", + "SendMessageParams", + "ProbeParams", + "DiagnoseParams", + "RepairParams", + "validate_params", + # protocols + "MetaProtocol", + "CapabilitiesProtocol", + "ChannelAccountSnapshot", + "ChannelStatusIssue", + "ConfigProtocol", + "ConfigSchemaProtocol", + "DefaultsProtocol", + "ReloadProtocol", + "SetupProtocol", + "SetupWizardProtocol", + "SetupWizardStep", + "DirectoryGroup", + "DirectoryPeer", + "DirectoryProtocol", + "DirectorySearchParams", + "DirectorySearchResult", + "DoctorProtocol", + "GatewayProtocol", + "OutboundProtocol", + "StatusProtocol", + "SecurityProtocol", + "PairingProtocol", + "GroupsProtocol", + "MentionsProtocol", + "StreamingProtocol", + "LifecycleProtocol", + "AgentPromptContext", + "AgentPromptProtocol", + "ReplyTransport", + "ThreadInfo", + "ThreadingProtocol", + "MessagingProtocol", + "BoundReplyPayload", + "SessionResolution", + "HeartbeatProtocol", + "AgentTool", + "AgentToolParam", + "AgentToolProtocol", + "MessageActionCapability", + "MessageActionProtocol", + "AuthProtocol", + "ElevatedProtocol", + "AllowlistManagementProtocol", + "MessageSendingHook", + "InboundMessageHook", + "SendLifecycleHook", + # protocol composition type aliases + "BasePluginProtocol", + "ConfigPluginProtocol", + "GatewayPluginProtocol", + "OutboundPluginProtocol", + "SessionPluginProtocol", + "FullMessagingPluginProtocol", + "WebhookPluginProtocol", + "PollingPluginProtocol", + "ApprovalPluginProtocol", + # hooks + "HookCallback", + "HookEvent", + "HookRegistration", + "LifecycleHookRegistry", + # message + "UnifiedMessage", + "DispatchResult", + "PeerInfo", + "GroupContext", + "MessageType", + "MentionSource", + "PeerKind", + "StreamingChunk", + "ChunkType", + "ReplyStage", + "ReplyPayload", + # routing + "SessionKeyBuilder", + # session + "SessionRecorder", + "SessionOpResult", + # security + "AllowlistChecker", + "AllowlistCheckResult", + "AllowlistHitReason", + "AllowlistStore", + "DmPolicyMode", + "GroupPolicyMode", + "InMemoryAllowlistStore", + "PostgresAllowlistStore", + "get_allowlist_checker", + "reset_allowlist_checker", + "create_allowlist_checker", + "set_allowlist_checker", + "IdentityLinkResolver", + "PairingManager", + "PairingStore", + "InMemoryPairingStore", + "PostgresPairingStore", + # utils + "run_sync", + "run_sync_partial", + "LazyClass", + "LazyModule", + "DurableMessageReceipt", + "DurableStrategy", + "MessageReceipt", + "MessageSendContext", + "MessageSendState", + # doctor models + "CheckStatus", + "ConnectivityCheckResult", + "CredentialCheckResult", + "DiagnosisResult", + "DiagnosisWarning", + "DoctorOptions", + "DoctorRepairMode", + "LegacyConfigRule", + "PermissionCheckResult", + "RepairResult", + "RepairStep", + "Severity", + # doctor runner + "ChannelDoctorRunner", + "doctor_runner", + "get_doctor_runner", + "set_doctor_runner", + "reset_doctor_runner", + # manager + "gateway", + "RetireResult", + "get_gateway", + "set_gateway", + "reset_gateway", + # streaming + "DraftStreamSession", + "DraftStreamState", + "StreamingConfig", + "DraftStreamLoop", + "FinalizableDraftStreamControls", + "BlockChunker", + "EmbeddedBlockChunker", + "StreamingBlock", + "StreamingStrategy", + "StreamingStrategySelector", + "get_tool_status_label", + "MessageAction", + "MessageActionRegistry", + "ActionDescriptor", + "ActionHandler", + "MESSAGE_ACTION_GROUPS", + "MESSAGE_ACTIONS_BASIC", + "MESSAGE_ACTIONS_GROUP", + "MESSAGE_ACTIONS_THREAD", + "MESSAGE_ACTIONS_POLL", + "MESSAGE_ACTIONS_ATTACHMENT", + "DispatchContext", + "dispatch_message_action", + "build_unified_message_tool_schema", + "get_channel_actions_schema", + "list_all_actions", + "list_channel_actions", + # render / security + "sanitize_text_content_preserve_entities", + "sanitize_url", + "parse_markdown", + "BlockQuote", + "Bold", + "BoldItalic", + "CodeBlock", + "Document", + "Heading", + "Image", + "InlineCode", + "IrNode", + "Italic", + "LineBreak", + "Link", + "ListItem", + "OrderedList", + "Paragraph", + "Strikethrough", + "Table", + "Text", + "ThematicBreak", + "UnorderedList", +] diff --git a/backend/package/yuxi/channel/sdk/actions/__init__.py b/backend/package/yuxi/channel/sdk/actions/__init__.py new file mode 100644 index 00000000..8584bbc2 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/actions/__init__.py @@ -0,0 +1,27 @@ +from yuxi.channel.sdk.actions.names import ( + MESSAGE_ACTION_GROUPS, + MESSAGE_ACTIONS_ATTACHMENT, + MESSAGE_ACTIONS_BASIC, + MESSAGE_ACTIONS_GROUP, + MESSAGE_ACTIONS_POLL, + MESSAGE_ACTIONS_THREAD, + MessageAction, +) +from yuxi.channel.sdk.actions.adapter import ( + ActionDescriptor, + ActionHandler, + MessageActionRegistry, +) + +__all__ = [ + "MessageAction", + "ActionDescriptor", + "ActionHandler", + "MessageActionRegistry", + "MESSAGE_ACTION_GROUPS", + "MESSAGE_ACTIONS_BASIC", + "MESSAGE_ACTIONS_GROUP", + "MESSAGE_ACTIONS_THREAD", + "MESSAGE_ACTIONS_POLL", + "MESSAGE_ACTIONS_ATTACHMENT", +] \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/actions/adapter.py b/backend/package/yuxi/channel/sdk/actions/adapter.py new file mode 100644 index 00000000..7461914a --- /dev/null +++ b/backend/package/yuxi/channel/sdk/actions/adapter.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable, Awaitable +from dataclasses import dataclass, field + +from yuxi.channel.sdk.actions.names import MessageAction + +logger = logging.getLogger(__name__) + +type ActionHandler = Callable[..., Awaitable[dict]] + + +@dataclass +class ActionDescriptor: + action: MessageAction + description: str = "" + parameters: list[dict] = field(default_factory=list) + requires_trusted_sender: bool = False + + def to_capability(self) -> dict: + return { + "action": self.action.value, + "description": self.description, + "parameters": self.parameters, + "requires_trusted_sender": self.requires_trusted_sender, + } + + +class MessageActionRegistry: + """消息动作注册表。 + + 渠道插件通过 register() 声明支持的动作及对应 handler。 + 一次注册同时提供 supports_action() 查询和 execute_action() 执行能力。 + 消除"声明了但没实现"或"实现了但没声明"的不一致。 + + 对齐 OpenClaw ChannelMessageActionAdapter 的设计理念: + - 注册即声明:register() 同时维护 handlers 和 descriptors + - supports_action() 和 execute_action() 共用同一注册表 + """ + + def __init__(self): + self._handlers: dict[MessageAction, ActionHandler] = {} + self._descriptors: dict[MessageAction, ActionDescriptor] = {} + + def register( + self, + action: MessageAction, + handler: ActionHandler, + *, + description: str = "", + parameters: list[dict] | None = None, + requires_trusted_sender: bool = False, + ) -> MessageActionRegistry: + self._handlers[action] = handler + self._descriptors[action] = ActionDescriptor( + action=action, + description=description, + parameters=parameters or [], + requires_trusted_sender=requires_trusted_sender, + ) + return self + + def supports_action(self, action: MessageAction | str) -> bool: + if isinstance(action, str): + try: + action = MessageAction(action) + except ValueError: + return False + return action in self._handlers + + def describe_actions(self) -> list[ActionDescriptor]: + return list(self._descriptors.values()) + + def list_actions(self) -> list[MessageAction]: + return list(self._handlers.keys()) + + def requires_trusted_sender(self, action: MessageAction | str) -> bool: + if isinstance(action, str): + try: + action = MessageAction(action) + except ValueError: + return False + desc = self._descriptors.get(action) + return desc.requires_trusted_sender if desc else False + + async def execute_action( + self, + action: MessageAction | str, + params: dict, + context: dict | None = None, + ) -> dict: + if isinstance(action, str): + try: + action = MessageAction(action) + except ValueError: + return {"success": False, "error": f"Unknown action: {action}"} + + handler = self._handlers.get(action) + if handler is None: + return { + "success": False, + "error": f"Action '{action.value}' is not supported by this channel", + } + + try: + if context: + return await handler(params=params, context=context) + return await handler(**params) + except Exception as e: + logger.exception("Action '%s' execution failed", action.value) + return {"success": False, "error": str(e)} + + def get_message_actions(self) -> list[dict]: + """兼容 BaseChannelPlugin.get_message_actions() 返回格式。""" + return [desc.to_capability() for desc in self.describe_actions()] \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/actions/discovery.py b/backend/package/yuxi/channel/sdk/actions/discovery.py new file mode 100644 index 00000000..ce7a1440 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/actions/discovery.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.channel.sdk.actions.names import MessageAction + +logger = logging.getLogger(__name__) + + +def list_all_actions() -> set[MessageAction]: + """列出所有已注册渠道支持的所有 action(对齐 OpenClaw listChannelMessageActions)。""" + actions: set[MessageAction] = {MessageAction.SEND} + for plugin in ChannelPluginRegistry.all(): + adapter = getattr(plugin, "actions", None) + if adapter is None: + continue + for action in adapter.list_actions(): + actions.add(action) + return actions + + +def list_channel_actions(channel_type: str) -> list[MessageAction]: + """列出指定渠道支持的 action 列表。""" + plugin = ChannelPluginRegistry.get(channel_type) + if plugin is None: + return [] + adapter = getattr(plugin, "actions", None) + if adapter is None: + return [] + return adapter.list_actions() + + +def get_channel_actions_schema(channel_type: str) -> list[dict]: + """获取指定渠道的 action schema(供 Agent tool 注册使用)。""" + plugin = ChannelPluginRegistry.get(channel_type) + if plugin is None: + return [] + adapter = getattr(plugin, "actions", None) + if adapter is None: + return [] + return [desc.to_capability() for desc in adapter.describe_actions()] + + +def build_unified_message_tool_schema() -> dict: + """构建跨所有渠道的统一 message tool schema(对齐 OpenClaw resolveChannelMessageToolSchemaProperties)。""" + all_actions = list_all_actions() + properties: dict[str, dict] = {} + + for channel_type in ChannelPluginRegistry.list_plugins(): + schema = get_channel_actions_schema(channel_type) + for action_desc in schema: + action_name = action_desc["action"] + if action_name not in properties: + properties[action_name] = { + "description": action_desc.get("description", ""), + "channels": [channel_type], + "parameters": action_desc.get("parameters", []), + } + else: + properties[action_name]["channels"].append(channel_type) + + return { + "name": "message", + "description": "Send or manage messages across channels", + "actions": sorted(all_actions), + "properties": properties, + } \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/actions/dispatch.py b/backend/package/yuxi/channel/sdk/actions/dispatch.py new file mode 100644 index 00000000..0358c7df --- /dev/null +++ b/backend/package/yuxi/channel/sdk/actions/dispatch.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from yuxi.channel.sdk.actions.names import MessageAction + +logger = logging.getLogger(__name__) + + +@dataclass +class DispatchContext: + """动作调度上下文(对齐 OpenClaw ChannelMessageActionContext)。""" + + channel_type: str + action: str + params: dict = field(default_factory=dict) + account_id: str = "default" + requester_sender_id: str | None = None + sender_is_owner: bool = False + session_key: str | None = None + agent_id: str | None = None + + +async def dispatch_message_action(ctx: DispatchContext) -> dict: + """统一消息动作调度入口。 + + 执行流程(对齐 OpenClaw dispatchChannelMessageAction): + 1. 获取插件 → 失败则返回 + 2. 获取 actions adapter → 失败则返回 + 3. 信任发送者检查 → 失败则返回 + 4. supports_action 检查 → 不支持则返回 + 5. 执行 + """ + from yuxi.channel.plugins.registry import ChannelPluginRegistry + + plugin = ChannelPluginRegistry.get(ctx.channel_type) + if plugin is None: + return { + "success": False, + "error": f"渠道 {ctx.channel_type} 未注册", + } + + adapter = getattr(plugin, "actions", None) + if adapter is None: + return { + "success": False, + "error": f"渠道 {ctx.channel_type} 未配置 MessageActionRegistry", + } + + if ( + adapter.requires_trusted_sender(ctx.action) + and not ctx.requester_sender_id + ): + return { + "success": False, + "error": f"Action '{ctx.action}' requires trusted sender identity", + } + + if not adapter.supports_action(ctx.action): + return { + "success": False, + "error": f"渠道 {ctx.channel_type} 不支持 action: {ctx.action}", + } + + result = await adapter.execute_action( + ctx.action, + params=ctx.params, + context={ + "channel_type": ctx.channel_type, + "account_id": ctx.account_id, + "requester_sender_id": ctx.requester_sender_id, + "sender_is_owner": ctx.sender_is_owner, + "session_key": ctx.session_key, + "agent_id": ctx.agent_id, + }, + ) + + return { + "success": result.get("success", False), + "message": result.get("error", ""), + "data": result if result.get("success") else {}, + } \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/actions/names.py b/backend/package/yuxi/channel/sdk/actions/names.py new file mode 100644 index 00000000..cb15a623 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/actions/names.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from enum import StrEnum + + +class MessageAction(StrEnum): + """全渠道统一消息动作命名空间。 + + 渠道插件通过此枚举声明支持的动作,替代自由字符串。 + 基于 OpenClaw 58 种动作逐项分析后,保留 Yuxi 渠道实际需要的核心动作。 + """ + + SEND = "send" + SEND_MEDIA = "send_media" + REPLY = "reply" + BROADCAST = "broadcast" + + EDIT = "edit" + UNSEND = "unsend" + + REACT = "react" + REACT_REMOVE = "react_remove" + REACTIONS = "reactions" + + PIN = "pin" + UNPIN = "unpin" + + POLL = "poll" + POLL_VOTE = "poll_vote" + + RENAME_GROUP = "rename_group" + ADD_PARTICIPANT = "add_participant" + REMOVE_PARTICIPANT = "remove_participant" + MEMBER_INFO = "member_info" + PERMISSIONS = "permissions" + + TOPIC_CREATE = "topic_create" + TOPIC_EDIT = "topic_edit" + THREAD_CREATE = "thread_create" + THREAD_LIST = "thread_list" + THREAD_REPLY = "thread_reply" + + READ = "read" + DELETE = "delete" + + +MESSAGE_ACTION_GROUPS: dict[str, list[MessageAction]] = { + "basic": [MessageAction.SEND, MessageAction.SEND_MEDIA, MessageAction.REPLY, MessageAction.BROADCAST], + "modify": [MessageAction.EDIT, MessageAction.UNSEND, MessageAction.DELETE], + "expression": [MessageAction.REACT, MessageAction.REACT_REMOVE, MessageAction.REACTIONS], + "pin": [MessageAction.PIN, MessageAction.UNPIN], + "poll": [MessageAction.POLL, MessageAction.POLL_VOTE], + "group": [ + MessageAction.RENAME_GROUP, + MessageAction.ADD_PARTICIPANT, + MessageAction.REMOVE_PARTICIPANT, + MessageAction.MEMBER_INFO, + MessageAction.PERMISSIONS, + ], + "thread": [ + MessageAction.THREAD_CREATE, + MessageAction.THREAD_LIST, + MessageAction.THREAD_REPLY, + MessageAction.TOPIC_CREATE, + MessageAction.TOPIC_EDIT, + ], + "utility": [MessageAction.READ], +} + +MESSAGE_ACTIONS_BASIC: tuple[MessageAction, ...] = tuple(MESSAGE_ACTION_GROUPS["basic"]) +MESSAGE_ACTIONS_GROUP: tuple[MessageAction, ...] = tuple(MESSAGE_ACTION_GROUPS["group"]) +MESSAGE_ACTIONS_THREAD: tuple[MessageAction, ...] = tuple(MESSAGE_ACTION_GROUPS["thread"]) +MESSAGE_ACTIONS_POLL: tuple[MessageAction, ...] = tuple(MESSAGE_ACTION_GROUPS["poll"]) +MESSAGE_ACTIONS_ATTACHMENT: tuple[MessageAction, ...] = () \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/async_lock.py b/backend/package/yuxi/channel/sdk/async_lock.py new file mode 100644 index 00000000..aabf8339 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/async_lock.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from typing import TypeVar + +T = TypeVar("T") + + +def create_async_lock() -> Callable[[Callable[[], Coroutine[None, None, T]]], Coroutine[None, None, T]]: + lock = asyncio.Lock() + + async def with_lock(task: Callable[[], Coroutine[None, None, T]]) -> T: + async with lock: + return await task() + + return with_lock diff --git a/backend/package/yuxi/channel/sdk/async_utils.py b/backend/package/yuxi/channel/sdk/async_utils.py new file mode 100644 index 00000000..c523f1d4 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/async_utils.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable, Coroutine +from typing import Protocol, TypeVar + +T = TypeVar("T") + +_logger = logging.getLogger(__name__) + + +class KeyedAsyncQueueHooks(Protocol): + def on_enqueue(self) -> None: ... + def on_settle(self) -> None: ... + + +def enqueue_keyed_task( + tails: dict[str, asyncio.Future], + key: str, + task: Callable[[], Coroutine[None, None, T]], + *, + hooks: KeyedAsyncQueueHooks | None = None, +) -> Coroutine[None, None, T]: + async def _run() -> T: + if hooks: + hooks.on_enqueue() + + prev = tails.get(key) + if prev is not None and not prev.done(): + try: + await prev + except Exception: + _logger.debug("Keyed task predecessor failed for key=%s", key, exc_info=True) + + loop = asyncio.get_running_loop() + tail: asyncio.Future = loop.create_future() + tails[key] = tail + + try: + return await task() + finally: + if hooks: + hooks.on_settle() + tail.set_result(None) + if tails.get(key) is tail: + tails.pop(key, None) + + return _run() + + +class KeyedAsyncQueue: + + def __init__(self) -> None: + self._tails: dict[str, asyncio.Future] = {} + + def get_tails(self) -> dict[str, asyncio.Future]: + return self._tails + + async def enqueue( + self, + key: str, + task: Callable[[], Coroutine[None, None, T]], + *, + hooks: KeyedAsyncQueueHooks | None = None, + ) -> T: + return await enqueue_keyed_task(self._tails, key, task, hooks=hooks) \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/channel_lifecycle.py b/backend/package/yuxi/channel/sdk/channel_lifecycle.py new file mode 100644 index 00000000..c21f43f8 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/channel_lifecycle.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +async def _maybe_await(result: Any) -> None: + if isinstance(result, Awaitable): + await result + + +async def wait_until_abort( + abort_event: asyncio.Event | None = None, + *, + on_abort: Callable[[], Any] | None = None, +) -> None: + if abort_event is None: + if on_abort is not None: + logger.warning("wait_until_abort: on_abort provided but abort_event is None, callback ignored") + await asyncio.Event().wait() + return + + await abort_event.wait() + if on_abort is not None: + await _maybe_await(on_abort()) + + +async def run_passive_account_lifecycle( + start: Callable[[], Awaitable[T]], + *, + abort_event: asyncio.Event | None = None, + stop: Callable[[T], Awaitable[Any]] | None = None, + on_stop: Callable[[], Awaitable[Any]] | None = None, +) -> None: + handle = await start() + + try: + await wait_until_abort(abort_event) + finally: + if stop is not None: + await stop(handle) + if on_stop is not None: + await on_stop() + + +async def keep_http_server_task_alive( + server_close_event: asyncio.Event, + *, + abort_event: asyncio.Event | None = None, + on_abort: Callable[[], Any] | None = None, +) -> None: + abort_triggered = False + + async def _trigger_abort() -> None: + nonlocal abort_triggered + if abort_triggered: + return + abort_triggered = True + if on_abort is not None: + await _maybe_await(on_abort()) + + async def _on_abort_event() -> None: + if abort_event is not None: + await abort_event.wait() + await _trigger_abort() + + abort_task = asyncio.create_task(_on_abort_event()) + + try: + await server_close_event.wait() + finally: + abort_task.cancel() + try: + await abort_task + except asyncio.CancelledError: + pass + await _trigger_abort() \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/dependencies.py b/backend/package/yuxi/channel/sdk/dependencies.py new file mode 100644 index 00000000..d77d09c4 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/dependencies.py @@ -0,0 +1,54 @@ +import asyncio +import contextvars +import logging +from dataclasses import dataclass, field +from typing import Any + +_logger = logging.getLogger(__name__) + + +@dataclass +class PluginResourceProvider: + http_client: Any | None = None + db_session_factory: Any | None = None + redis_client: Any | None = None + config_provider: Any | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + async def cleanup(self) -> None: + for attr_name in ("http_client", "redis_client"): + resource = getattr(self, attr_name, None) + if resource is None: + continue + close_fn = getattr(resource, "close", None) or getattr(resource, "aclose", None) + if close_fn is None: + continue + try: + result = close_fn() + if asyncio.iscoroutine(result): + await result + _logger.debug("PluginResourceProvider: closed %s", attr_name) + except Exception: + _logger.warning("PluginResourceProvider: failed to close %s", attr_name) + self.http_client = None + self.db_session_factory = None + self.redis_client = None + self.config_provider = None + self.metadata.clear() + + +_resources_ctx: contextvars.ContextVar[PluginResourceProvider] = contextvars.ContextVar( + "plugin_resources", default=PluginResourceProvider() +) + + +def inject_resources(provider: PluginResourceProvider) -> None: + _resources_ctx.set(provider) + + +def get_resources() -> PluginResourceProvider: + return _resources_ctx.get() + + +def reset_resources() -> None: + _resources_ctx.set(PluginResourceProvider()) diff --git a/backend/package/yuxi/channel/sdk/targets/__init__.py b/backend/package/yuxi/channel/sdk/targets/__init__.py new file mode 100644 index 00000000..49af50da --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/__init__.py @@ -0,0 +1,26 @@ +from yuxi.channel.sdk.targets.display import format_target_display +from yuxi.channel.sdk.targets.envelope import TargetToEnvelopeAdapter +from yuxi.channel.sdk.targets.extractors import TargetFieldExtractor, TelegramTargetExtractor +from yuxi.channel.sdk.targets.normalizer import detect_target_kind, looks_like_target_id, normalize_target_input +from yuxi.channel.sdk.targets.pipeline import TargetResolverPipeline +from yuxi.channel.sdk.targets.types import ( + AmbiguousMode, + ResolveError, + ResolveErrorKind, + ResolvedTarget, +) + +__all__ = [ + "AmbiguousMode", + "ResolveError", + "ResolveErrorKind", + "ResolvedTarget", + "TargetFieldExtractor", + "TargetResolverPipeline", + "TargetToEnvelopeAdapter", + "TelegramTargetExtractor", + "detect_target_kind", + "format_target_display", + "looks_like_target_id", + "normalize_target_input", +] diff --git a/backend/package/yuxi/channel/sdk/targets/display.py b/backend/package/yuxi/channel/sdk/targets/display.py new file mode 100644 index 00000000..7d05b2d4 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/display.py @@ -0,0 +1,35 @@ +def format_target_display( + target: str, + *, + display: str = "", + kind: str = "", + channel: str = "", +) -> str: + trimmed = target.strip() + display_trimmed = display.strip() + + if display_trimmed: + if kind == "user": + return f"@{display_trimmed}" + if kind in ("group", "channel"): + return f"#{display_trimmed}" + return display_trimmed + + if trimmed.startswith("@") or trimmed.startswith("#"): + return trimmed + + lowered = trimmed.lower() + channel_prefix = f"{channel.lower()}:" + core = trimmed[len(channel_prefix) :] if lowered.startswith(channel_prefix) else trimmed + + if core.lower().startswith("channel:"): + return f"#{core.split(':', 1)[1]}" + if core.lower().startswith("user:"): + return f"@{core.split(':', 1)[1]}" + + if kind == "user": + return f"@{core}" + if kind in ("group", "channel"): + return f"#{core}" + + return core diff --git a/backend/package/yuxi/channel/sdk/targets/envelope.py b/backend/package/yuxi/channel/sdk/targets/envelope.py new file mode 100644 index 00000000..2577b407 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/envelope.py @@ -0,0 +1,62 @@ +from yuxi.channel.message.models import UnifiedMessage +from yuxi.channel.sdk.targets.extractors import TargetFieldExtractor, TelegramTargetExtractor +from yuxi.channel.sdk.targets.pipeline import TargetResolverPipeline + + +class TargetToEnvelopeAdapter: + def __init__( + self, + pipeline: TargetResolverPipeline, + self_user_ids: dict[str, set[str]], + *, + extractor: TargetFieldExtractor | None = None, + ): + self._pipeline = pipeline + self._self_user_ids = self_user_ids + self._extractor = extractor or TelegramTargetExtractor() + + async def build( + self, + raw_message: dict, + *, + channel: str, + account_id: str, + ) -> UnifiedMessage | None: + sender = self._extractor.extract_sender(raw_message) + + if self._is_echo(sender.id, channel, account_id): + return None + + return self._assemble_message(raw_message, sender, channel, account_id) + + def _is_echo(self, sender_id: str, channel: str, account_id: str) -> bool: + key = f"{channel}:{account_id}" + self_ids = self._self_user_ids.get(key) + if not self_ids: + return False + return sender_id in self_ids + + def _assemble_message( + self, + raw_message: dict, + sender, + channel: str, + account_id: str, + ) -> UnifiedMessage: + content = self._extractor.extract_content(raw_message) + media_urls = self._extractor.extract_media_urls(raw_message) + msg_type = self._extractor.detect_message_type(raw_message) + group = self._extractor.extract_group(raw_message) + msg_id = self._extractor.extract_message_id(raw_message) + + return UnifiedMessage( + msg_id=msg_id, + message_type=msg_type, + content=content, + sender=sender, + channel_type=channel, + account_id=account_id, + group=group, + media_urls=media_urls, + raw_payload=raw_message, + ) diff --git a/backend/package/yuxi/channel/sdk/targets/extractors.py b/backend/package/yuxi/channel/sdk/targets/extractors.py new file mode 100644 index 00000000..f1540fa8 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/extractors.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Protocol + +from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo +from yuxi.channel.routing.models import PeerKind + + +class TargetFieldExtractor(Protocol): + def extract_sender(self, raw_message: dict) -> PeerInfo: ... + + def extract_content(self, raw_message: dict) -> str: ... + + def extract_media_urls(self, raw_message: dict) -> list[str]: ... + + def extract_group(self, raw_message: dict) -> GroupContext | None: ... + + def detect_message_type(self, raw_message: dict) -> MessageType: ... + + def extract_message_id(self, raw_message: dict) -> str: ... + + +class TelegramTargetExtractor: + + @staticmethod + def _msg(raw_message: dict) -> dict: + return raw_message.get("message", raw_message) + + def extract_sender(self, raw_message: dict) -> PeerInfo: + msg = self._msg(raw_message) + sender_data = msg.get("from", {}) + return PeerInfo( + kind=PeerKind.DIRECT, + id=str(sender_data.get("id", "")), + display_name=sender_data.get("first_name", sender_data.get("name")), + username=sender_data.get("username"), + is_bot=sender_data.get("is_bot", False), + ) + + def extract_content(self, raw_message: dict) -> str: + msg = self._msg(raw_message) + return str(msg.get("text", msg.get("body", msg.get("caption", ""))) or "") + + def extract_media_urls(self, raw_message: dict) -> list[str]: + msg = self._msg(raw_message) + urls: list[str] = [] + for field in ("photo", "document", "video", "audio", "image"): + if field in msg: + info = msg[field] + if isinstance(info, list) and info: + last = info[-1] + if isinstance(last, dict): + urls.append(str(last.get("file_id", last.get("url", "")))) + elif isinstance(info, dict): + urls.append(str(info.get("file_id", info.get("url", "")))) + return urls + + def extract_group(self, raw_message: dict) -> GroupContext | None: + msg = self._msg(raw_message) + chat = msg.get("chat", {}) + if chat.get("type") in ("group", "supergroup", "channel"): + return GroupContext( + id=str(chat.get("id", "")), + name=chat.get("title"), + thread_id=str(msg.get("message_thread_id")) if msg.get("is_topic_message") else None, + ) + return None + + def detect_message_type(self, raw_message: dict) -> MessageType: + msg = self._msg(raw_message) + if any(k in msg for k in ("photo", "document", "video", "audio", "image", "voice")): + return MessageType.FILE + if msg.get("text") or msg.get("body") or msg.get("caption"): + return MessageType.TEXT + return MessageType.EVENT + + def extract_message_id(self, raw_message: dict) -> str: + msg = self._msg(raw_message) + return str(msg.get("message_id", msg.get("id", ""))) \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/targets/normalizer.py b/backend/package/yuxi/channel/sdk/targets/normalizer.py new file mode 100644 index 00000000..19d80c09 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/normalizer.py @@ -0,0 +1,50 @@ +def normalize_target_input(raw: str) -> str: + if not raw: + return "" + cleaned = raw.strip() + if ":" in cleaned: + prefix, rest = cleaned.split(":", 1) + return f"{prefix.lower().strip()}:{rest.strip()}" + return cleaned + + +def detect_target_kind(raw: str, channel_chat_types: list[str] | None = None) -> str: + trimmed = raw.strip() + + if trimmed.startswith("@") or trimmed.lower().startswith("user:"): + return "user" + if trimmed.startswith("#") or trimmed.lower().startswith("channel:"): + return "group" + if trimmed.lower().startswith("thread:"): + return "channel" + + if channel_chat_types: + if "direct" in channel_chat_types and "group" not in channel_chat_types: + return "user" + + return "group" + + +def looks_like_target_id(raw: str) -> bool: + trimmed = raw.strip() + core = trimmed + for prefix in ("user:", "channel:", "group:", "thread:", "@", "#"): + if core.lower().startswith(prefix): + core = core[len(prefix) :].strip() + break + + if not core: + return False + + if core.isdigit(): + return True + + if any("\u4e00" <= c <= "\u9fff" for c in core): + return False + if " " in core: + return False + + if "-" in core and len(core) > 20: + return True + + return False diff --git a/backend/package/yuxi/channel/sdk/targets/pipeline.py b/backend/package/yuxi/channel/sdk/targets/pipeline.py new file mode 100644 index 00000000..e947129e --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/pipeline.py @@ -0,0 +1,182 @@ +import logging +import time +from collections.abc import Callable + +from yuxi.channel.protocols import DirectoryEntry +from yuxi.channel.sdk.targets.normalizer import ( + detect_target_kind, + looks_like_target_id, + normalize_target_input, +) +from yuxi.channel.sdk.targets.types import ( + AmbiguousMode, + ResolveError, + ResolveErrorKind, + ResolvedTarget, +) + +logger = logging.getLogger(__name__) + +PluginTargetResolver = Callable[[str, str], ResolvedTarget | None] + + +class TargetResolverPipeline: + def __init__( + self, + *, + directory=None, + directory_config: dict | None = None, + directory_account_id: str | None = None, + plugin_resolver: PluginTargetResolver | None = None, + plugin_hint: str = "", + ambiguous_mode: AmbiguousMode = AmbiguousMode.ERROR, + cache_ttl: float = 1800.0, + ): + self._directory = directory + self._directory_config = directory_config or {} + self._directory_account_id = directory_account_id + self._plugin_resolver = plugin_resolver + self._plugin_hint = plugin_hint + self._ambiguous_mode = ambiguous_mode + self._cache: dict[str, tuple[float, list[DirectoryEntry]]] = {} + self._cache_ttl = cache_ttl + + async def resolve(self, raw_input: str) -> ResolvedTarget: + normalized = normalize_target_input(raw_input) + if not normalized: + raise ResolveError( + kind=ResolveErrorKind.EMPTY_INPUT, + message="目标输入为空", + hint=self._plugin_hint, + ) + + kind = detect_target_kind(normalized) + + if looks_like_target_id(normalized): + return ResolvedTarget( + to=normalized, + kind=kind, + display=normalized, + source="normalized", + ) + + if self._directory is not None: + try: + entry = await self._search_directory(normalized, kind) + if entry is not None: + return entry + except ResolveError as e: + if e.kind == ResolveErrorKind.AMBIGUOUS and self._ambiguous_mode != AmbiguousMode.ERROR: + best = self._pick_ambiguous(e.candidates, self._ambiguous_mode) + if best is not None: + return ResolvedTarget( + to=best.id, + kind=kind, + display=best.name or best.handle or "", + source="directory", + confidence=0.5, + ) + raise + + if self._plugin_resolver is not None: + fallback = self._plugin_resolver(normalized, kind) + if fallback is not None: + return ResolvedTarget( + to=fallback.to, + kind=kind, + display=fallback.display or normalized, + source="fallback", + confidence=0.3, + ) + + raise ResolveError( + kind=ResolveErrorKind.NOT_FOUND, + message=f"未找到目标: {normalized}", + hint=self._plugin_hint, + ) + + async def _search_directory(self, query: str, kind: str) -> ResolvedTarget | None: + cache_key = f"{kind}" + now = time.monotonic() + + if cache_key in self._cache: + ts, entries = self._cache[cache_key] + if now - ts < self._cache_ttl: + return self._match_entries(query, entries, kind) + + try: + if kind == "user": + entries = await self._directory.list_peers_live( + config=self._directory_config, + account_id=self._directory_account_id, + query=query, + limit=20, + ) + else: + entries = await self._directory.list_groups_live( + config=self._directory_config, + account_id=self._directory_account_id, + query=query, + limit=20, + ) + except Exception: + logger.debug("Directory lookup failed for query=%s kind=%s", query, kind) + return None + + self._cache[cache_key] = (now, entries) + return self._match_entries(query, entries, kind) + + def _match_entries(self, query: str, entries: list[DirectoryEntry], kind: str) -> ResolvedTarget | None: + matches = [entry for entry in entries if self._entry_matches(query, entry)] + + if len(matches) == 1: + entry = matches[0] + return ResolvedTarget( + to=entry.id, + kind=kind, + display=entry.name or entry.handle or "", + source="directory", + ) + + if len(matches) > 1: + if self._ambiguous_mode == AmbiguousMode.ERROR: + raise ResolveError( + kind=ResolveErrorKind.AMBIGUOUS, + message=f"找到 {len(matches)} 个匹配目标", + candidates=matches, + hint=self._plugin_hint, + ) + best = self._pick_ambiguous(matches, self._ambiguous_mode) + if best is not None: + return ResolvedTarget( + to=best.id, + kind=kind, + display=best.name or best.handle or "", + source="directory", + confidence=0.5, + ) + + return None + + @staticmethod + def _entry_matches(query: str, entry: DirectoryEntry) -> bool: + q = query.lower().strip().lstrip("@").lstrip("#") + candidates = [ + entry.id.lower(), + (entry.name or "").lower(), + (entry.handle or "").lower(), + ] + return any(q in c for c in candidates if c) + + @staticmethod + def _pick_ambiguous(candidates: list[DirectoryEntry], mode: AmbiguousMode) -> DirectoryEntry | None: + if not candidates: + return None + if mode == AmbiguousMode.FIRST: + return candidates[0] + ranked = sorted( + candidates, + key=lambda e: e.rank if isinstance(e.rank, (int, float)) else 0, + reverse=True, + ) + return ranked[0] diff --git a/backend/package/yuxi/channel/sdk/targets/types.py b/backend/package/yuxi/channel/sdk/targets/types.py new file mode 100644 index 00000000..94c54bc3 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/targets/types.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, field +from enum import StrEnum + +from yuxi.channel.protocols import DirectoryEntry + + +class AmbiguousMode(StrEnum): + ERROR = "error" + BEST = "best" + FIRST = "first" + + +class ResolveErrorKind(StrEnum): + EMPTY_INPUT = "empty_input" + NOT_FOUND = "not_found" + AMBIGUOUS = "ambiguous" + UNSUPPORTED_KIND = "unsupported_kind" + PLUGIN_ERROR = "plugin_error" + + +@dataclass +class ResolvedTarget: + to: str + kind: str + display: str = "" + source: str = "normalized" + confidence: float = 1.0 + + +@dataclass +class ResolveError(Exception): + kind: ResolveErrorKind + message: str + candidates: list[DirectoryEntry] = field(default_factory=list) + hint: str = "" diff --git a/backend/package/yuxi/channel/sdk/test_utils.py b/backend/package/yuxi/channel/sdk/test_utils.py new file mode 100644 index 00000000..1d8d8b86 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/test_utils.py @@ -0,0 +1,27 @@ +from typing import Any + +from yuxi.channel.sdk.dependencies import PluginResourceProvider, inject_resources + + +def create_test_resources( + http_client: Any | None = None, + db_session_factory: Any | None = None, + redis_client: Any | None = None, + config_provider: Any | None = None, + metadata: dict | None = None, + **kwargs, +) -> PluginResourceProvider: + provider = PluginResourceProvider( + http_client=http_client, + db_session_factory=db_session_factory, + redis_client=redis_client, + config_provider=config_provider, + metadata=metadata or {}, + ) + for key, value in kwargs.items(): + setattr(provider, key, value) + return provider + + +def reset_test_resources() -> None: + inject_resources(PluginResourceProvider()) \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/__init__.py b/backend/package/yuxi/channel/sdk/testing/__init__.py new file mode 100644 index 00000000..2aeec5ab --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/__init__.py @@ -0,0 +1,42 @@ +from yuxi.channel.sdk.testing.contracts import ( + ChannelContractTest, + ConfigProtocolContract, + ContractTestBase, + MetaProtocolContract, + OutboundPayloadContract, + SecurityProtocolContract, + StatusProtocolContract, +) +from yuxi.channel.sdk.testing.fixtures import FixtureFactory +from yuxi.channel.sdk.testing.helpers import ( + assert_msg_id_format, + assert_send_call_contains, + run_async, +) +from yuxi.channel.sdk.testing.mocks import ( + SentMessage, + create_mock_binding, + create_mock_channel_plugin, + create_mock_gateway_client, +) +from yuxi.channel.sdk.testing.testkit import SendTestHarness, install_contract_suite + +__all__ = [ + "ContractTestBase", + "ChannelContractTest", + "ConfigProtocolContract", + "MetaProtocolContract", + "OutboundPayloadContract", + "SecurityProtocolContract", + "StatusProtocolContract", + "FixtureFactory", + "assert_msg_id_format", + "assert_send_call_contains", + "run_async", + "SentMessage", + "create_mock_binding", + "create_mock_channel_plugin", + "create_mock_gateway_client", + "SendTestHarness", + "install_contract_suite", +] \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/contracts.py b/backend/package/yuxi/channel/sdk/testing/contracts.py new file mode 100644 index 00000000..88726326 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/contracts.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.protocols import ( + CapabilitiesProtocol, + ConfigProtocol, + MetaProtocol, + OutboundProtocol, + SecurityProtocol, + StatusProtocol, +) + + +class ContractTestBase: + plugin: MetaProtocol + plugin_capabilities: ChannelCapabilities | None = None + + async def setup_method(self) -> None: + if hasattr(self.plugin, "capabilities"): + self.plugin_capabilities = self.plugin.capabilities + + async def test_meta_protocol(self) -> None: + assert isinstance(self.plugin.id, str) and self.plugin.id, "plugin.id must be a non-empty string" + assert isinstance(self.plugin.name, str) and self.plugin.name, "plugin.name must be a non-empty string" + if hasattr(self.plugin, "label") and self.plugin.label is not None: + assert isinstance(self.plugin.label, str), "plugin.label must be a string" + + async def test_capabilities(self) -> None: + if not isinstance(self.plugin, CapabilitiesProtocol): + return + caps = self.plugin.capabilities + assert isinstance(caps, ChannelCapabilities), "capabilities must be ChannelCapabilities" + for ct in caps.chat_types: + assert ct in ("direct", "group"), f"Unknown chat_type: {ct}" + for mt in caps.message_types: + assert mt in ("text", "image", "video", "audio", "file", "location"), f"Unknown message_type: {mt}" + if caps.streaming_mode is not None and caps.streaming: + valid_modes = ("card_kit", "block", "c2c_stream_api", "raw") + assert caps.streaming_mode in valid_modes, f"Unknown streaming_mode: {caps.streaming_mode}" + if caps.block_streaming: + assert caps.block_streaming_chunk_min_chars <= caps.block_streaming_chunk_max_chars, \ + "block_streaming_chunk_min_chars must be <= block_streaming_chunk_max_chars" + + async def test_config_protocol(self) -> None: + if not isinstance(self.plugin, ConfigProtocol): + return + default_config = self.plugin.get_default_config() + assert isinstance(default_config, dict), "get_default_config must return a dict" + + async def test_all_contracts(self) -> None: + await self.setup_method() + await self.test_meta_protocol() + await self.test_capabilities() + await self.test_config_protocol() + + +class MetaProtocolContract(ABC): + + @abstractmethod + def make_plugin(self): + ... + + async def test_plugin_has_id(self): + plugin = self.make_plugin() + assert plugin.id, "plugin must have a non-empty id" + + async def test_plugin_has_name(self): + plugin = self.make_plugin() + assert plugin.name, "plugin must have a non-empty name" + + async def test_capabilities_chat_types_non_empty(self): + plugin = self.make_plugin() + caps = plugin.capabilities + assert isinstance(caps, ChannelCapabilities) + assert caps.chat_types, "capabilities must declare at least one chat_type" + + +class ConfigProtocolContract(ABC): + + @abstractmethod + def make_plugin(self): + ... + + async def test_list_account_ids_returns_list(self): + plugin = self.make_plugin() + if not isinstance(plugin, ConfigProtocol): + return + ids = plugin.list_account_ids({}) + assert isinstance(ids, list), "list_account_ids must return a list" + + async def test_resolve_account_returns_dict(self): + plugin = self.make_plugin() + if not isinstance(plugin, ConfigProtocol): + return + result = await plugin.resolve_account("test_account") + assert isinstance(result, dict) + + async def test_is_configured_returns_bool(self): + plugin = self.make_plugin() + if not isinstance(plugin, ConfigProtocol): + return + result = plugin.is_configured({"id": "test", "enabled": True}) + assert isinstance(result, bool) + + +class OutboundPayloadContract(ABC): + + @abstractmethod + def make_plugin(self): + ... + + async def test_send_text_accepts_target_content(self): + plugin = self.make_plugin() + if not isinstance(plugin, OutboundProtocol): + return + await plugin.send_text("target_001", "contract test message") + + async def test_send_media_accepts_media_url(self): + plugin = self.make_plugin() + if not isinstance(plugin, OutboundProtocol): + return + await plugin.send_media("target_001", "https://example.com/a.jpg", "image") + + +class SecurityProtocolContract(ABC): + + @abstractmethod + def make_plugin(self): + ... + + async def test_check_allowlist_returns_bool(self): + plugin = self.make_plugin() + if not isinstance(plugin, SecurityProtocol): + return + result = await plugin.check_allowlist("test_user", "direct") + assert isinstance(result, bool), "check_allowlist must return bool" + + +class StatusProtocolContract(ABC): + + @abstractmethod + def make_plugin(self): + ... + + async def test_probe_returns_bool(self): + plugin = self.make_plugin() + if not isinstance(plugin, StatusProtocol): + return + result = await plugin.probe() + assert isinstance(result, bool) + + async def test_build_summary_returns_dict(self): + plugin = self.make_plugin() + if not isinstance(plugin, StatusProtocol): + return + result = plugin.build_summary(None) + assert isinstance(result, dict) + + +class ChannelContractTest( + MetaProtocolContract, + ConfigProtocolContract, + OutboundPayloadContract, + SecurityProtocolContract, + StatusProtocolContract, + ABC, +): + pass \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/fixtures.py b/backend/package/yuxi/channel/sdk/testing/fixtures.py new file mode 100644 index 00000000..322b10d4 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/fixtures.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from yuxi.channel.capabilities import ChannelCapabilities + + +@dataclass +class FixtureFactory: + @staticmethod + def capabilities(**overrides) -> ChannelCapabilities: + defaults = { + "chat_types": ["direct", "group"], + "message_types": ["text"], + "streaming": False, + "streaming_mode": None, + "block_streaming": False, + } + defaults.update(overrides) + return ChannelCapabilities(**defaults) + + @staticmethod + def preview_streaming_caps() -> ChannelCapabilities: + return FixtureFactory.capabilities( + streaming=True, + streaming_mode=None, + ) + + @staticmethod + def block_streaming_caps() -> ChannelCapabilities: + return FixtureFactory.capabilities( + streaming=True, + streaming_mode="block", + block_streaming=True, + ) + + @staticmethod + def card_kit_caps() -> ChannelCapabilities: + return FixtureFactory.capabilities( + streaming=True, + streaming_mode="card_kit", + adaptive_cards=True, + ) + + @staticmethod + def no_streaming_caps() -> ChannelCapabilities: + return FixtureFactory.capabilities( + streaming=False, + ) \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/helpers.py b/backend/package/yuxi/channel/sdk/testing/helpers.py new file mode 100644 index 00000000..3eb17ca3 --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/helpers.py @@ -0,0 +1,25 @@ +import asyncio + + +def assert_msg_id_format(msg_id: str): + assert isinstance(msg_id, str), f"msg_id must be str, got {type(msg_id)}" + assert msg_id.strip(), "msg_id must not be empty or whitespace-only" + + +def run_async(fn): + return asyncio.run(fn) + + +def assert_send_call_contains( + sent: list, + index: int, + *, + text_contains: str | None = None, + target_id: str | None = None, +): + assert index < len(sent), f"expected at least {index + 1} send calls, got {len(sent)}" + msg = sent[index] + if text_contains is not None: + assert text_contains in msg.text, f"send[{index}] text does not contain '{text_contains}'" + if target_id is not None: + assert msg.target_id == target_id, f"send[{index}] target_id mismatch" \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/mocks.py b/backend/package/yuxi/channel/sdk/testing/mocks.py new file mode 100644 index 00000000..aefcca9e --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/mocks.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from yuxi.channel.capabilities import ChannelCapabilities + + +@dataclass +class SentMessage: + text: str + target_id: str = "" + msg_id: str = "" + kwargs: dict = field(default_factory=dict) + + +def create_mock_channel_plugin( + channel_id: str = "mock_channel", + capabilities: ChannelCapabilities | None = None, + *, + with_gateway: bool = False, + with_status: bool = False, + account_ids: list[str] | None = None, +) -> Any: + + class _MockPlugin: + id = channel_id + name = f"Mock {channel_id}" + order = 10 + + def __init__(self): + self._sent: list[SentMessage] = [] + + plugin = _MockPlugin() + _caps = capabilities or ChannelCapabilities(chat_types=["direct", "group"], message_types=["text"]) + + setattr(plugin.__class__, "capabilities", property(lambda self: _caps)) + + if account_ids is None: + account_ids = ["mock_account"] + + def list_account_ids(config: dict | None = None) -> list[str]: + if config is None: + config = {} + return list(account_ids) + + async def resolve_account(account_id: str) -> dict: + return {"id": account_id, "enabled": True} + + def is_configured(account: dict) -> bool: + return True + + plugin.list_account_ids = list_account_ids + plugin.resolve_account = resolve_account + plugin.is_configured = is_configured + + async def send_text(target_id: str, content: str, *, reply_to_id=None, thread_id=None, account_id=None) -> str: + msg_id = f"mock_msg_{len(plugin._sent) + 1}" + plugin._sent.append(SentMessage( + text=content, + target_id=target_id, + msg_id=msg_id, + kwargs={"reply_to_id": reply_to_id, "thread_id": thread_id, "account_id": account_id}, + )) + return msg_id + + async def send_media( + target_id: str, media_url: str, media_type: str, *, reply_to_id=None, thread_id=None, account_id=None + ) -> str: + msg_id = f"mock_media_{len(plugin._sent) + 1}" + plugin._sent.append(SentMessage( + text=f"[media: {media_url}]", + target_id=target_id, + msg_id=msg_id, + kwargs={"media_url": media_url, "media_type": media_type, "reply_to_id": reply_to_id}, + )) + return msg_id + + async def edit_message(target_id: str, message_id: str, content: str, *, thread_id=None, account_id=None) -> str: + return message_id + + plugin.send_text = send_text + plugin.send_media = send_media + plugin.edit_message = edit_message + + async def check_allowlist(peer_id: str, channel_type: str) -> bool: + return True + + plugin.check_allowlist = check_allowlist + + if with_gateway: + async def start(ctx): return {"ok": True} + async def stop(ctx): pass + plugin.start = start + plugin.stop = stop + + if with_status: + async def probe(account: dict | None = None) -> bool: + return True + def build_summary(snapshot: object) -> dict: + return {"ok": True} + plugin.probe = probe + plugin.build_summary = build_summary + + return plugin + + +def create_mock_gateway_client(): + client = MagicMock() + client.call = AsyncMock(return_value={"success": True}) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + return client + + +def create_mock_binding( + agent_config_id: int = 1, + channel: str = "mock_channel", + account_id: str = "mock_account", + dm_scope: str = "per-channel-peer", + priority: int = 0, +): + from yuxi.channel.routing.models import BindingMatch, PeerConstraint, PeerKind, RouteBinding + + peer = PeerConstraint(kind=PeerKind.DIRECT, peer_id=None) + match = BindingMatch(channel=channel, account_id=account_id, peer=peer) + return RouteBinding( + agent_config_id=agent_config_id, + match=match, + dm_scope=dm_scope, + priority=priority, + ) \ No newline at end of file diff --git a/backend/package/yuxi/channel/sdk/testing/testkit.py b/backend/package/yuxi/channel/sdk/testing/testkit.py new file mode 100644 index 00000000..3d855b9a --- /dev/null +++ b/backend/package/yuxi/channel/sdk/testing/testkit.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Any, Callable + + +def install_contract_suite( + channel_id: str, + plugin_factory: Callable[[], Any], +): + def _decorator(cls): + cls.make_plugin = staticmethod(plugin_factory) + return cls + return _decorator + + +@dataclass +class SendTestHarness: + plugin: Any + to: str = "test_target" + sent_count: int = 0 + + async def run_send_text(self, content: str, **kwargs): + msg_id = await self.plugin.send_text(self.to, content, **kwargs) + self.sent_count += 1 + return msg_id + + async def run_send_media(self, media_url: str, media_type: str = "image", **kwargs): + msg_id = await self.plugin.send_media(self.to, media_url, media_type, **kwargs) + self.sent_count += 1 + return msg_id + + @property + def sent_messages(self): + return self.plugin._sent \ No newline at end of file