feat(channel-sdk): 新增完整的渠道SDK工具链
本提交新增了全渠道SDK核心模块: 1. 异步锁、目标解析、动作调度等基础工具 2. 消息动作注册与统一调度系统 3. 测试套件与契约测试框架 4. 完整的目标解析流水线与工具函数 5. 资源依赖注入与生命周期管理
This commit is contained in:
parent
d52e9b518d
commit
b438af3ba8
485
backend/package/yuxi/channel/sdk/__init__.py
Normal file
485
backend/package/yuxi/channel/sdk/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
27
backend/package/yuxi/channel/sdk/actions/__init__.py
Normal file
27
backend/package/yuxi/channel/sdk/actions/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
116
backend/package/yuxi/channel/sdk/actions/adapter.py
Normal file
116
backend/package/yuxi/channel/sdk/actions/adapter.py
Normal file
@ -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()]
|
||||
68
backend/package/yuxi/channel/sdk/actions/discovery.py
Normal file
68
backend/package/yuxi/channel/sdk/actions/discovery.py
Normal file
@ -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,
|
||||
}
|
||||
83
backend/package/yuxi/channel/sdk/actions/dispatch.py
Normal file
83
backend/package/yuxi/channel/sdk/actions/dispatch.py
Normal file
@ -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 {},
|
||||
}
|
||||
74
backend/package/yuxi/channel/sdk/actions/names.py
Normal file
74
backend/package/yuxi/channel/sdk/actions/names.py
Normal file
@ -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, ...] = ()
|
||||
17
backend/package/yuxi/channel/sdk/async_lock.py
Normal file
17
backend/package/yuxi/channel/sdk/async_lock.py
Normal file
@ -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
|
||||
67
backend/package/yuxi/channel/sdk/async_utils.py
Normal file
67
backend/package/yuxi/channel/sdk/async_utils.py
Normal file
@ -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)
|
||||
83
backend/package/yuxi/channel/sdk/channel_lifecycle.py
Normal file
83
backend/package/yuxi/channel/sdk/channel_lifecycle.py
Normal file
@ -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()
|
||||
54
backend/package/yuxi/channel/sdk/dependencies.py
Normal file
54
backend/package/yuxi/channel/sdk/dependencies.py
Normal file
@ -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())
|
||||
26
backend/package/yuxi/channel/sdk/targets/__init__.py
Normal file
26
backend/package/yuxi/channel/sdk/targets/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
35
backend/package/yuxi/channel/sdk/targets/display.py
Normal file
35
backend/package/yuxi/channel/sdk/targets/display.py
Normal file
@ -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
|
||||
62
backend/package/yuxi/channel/sdk/targets/envelope.py
Normal file
62
backend/package/yuxi/channel/sdk/targets/envelope.py
Normal file
@ -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,
|
||||
)
|
||||
79
backend/package/yuxi/channel/sdk/targets/extractors.py
Normal file
79
backend/package/yuxi/channel/sdk/targets/extractors.py
Normal file
@ -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", "")))
|
||||
50
backend/package/yuxi/channel/sdk/targets/normalizer.py
Normal file
50
backend/package/yuxi/channel/sdk/targets/normalizer.py
Normal file
@ -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
|
||||
182
backend/package/yuxi/channel/sdk/targets/pipeline.py
Normal file
182
backend/package/yuxi/channel/sdk/targets/pipeline.py
Normal file
@ -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]
|
||||
35
backend/package/yuxi/channel/sdk/targets/types.py
Normal file
35
backend/package/yuxi/channel/sdk/targets/types.py
Normal file
@ -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 = ""
|
||||
27
backend/package/yuxi/channel/sdk/test_utils.py
Normal file
27
backend/package/yuxi/channel/sdk/test_utils.py
Normal file
@ -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())
|
||||
42
backend/package/yuxi/channel/sdk/testing/__init__.py
Normal file
42
backend/package/yuxi/channel/sdk/testing/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
170
backend/package/yuxi/channel/sdk/testing/contracts.py
Normal file
170
backend/package/yuxi/channel/sdk/testing/contracts.py
Normal file
@ -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
|
||||
49
backend/package/yuxi/channel/sdk/testing/fixtures.py
Normal file
49
backend/package/yuxi/channel/sdk/testing/fixtures.py
Normal file
@ -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,
|
||||
)
|
||||
25
backend/package/yuxi/channel/sdk/testing/helpers.py
Normal file
25
backend/package/yuxi/channel/sdk/testing/helpers.py
Normal file
@ -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"
|
||||
131
backend/package/yuxi/channel/sdk/testing/mocks.py
Normal file
131
backend/package/yuxi/channel/sdk/testing/mocks.py
Normal file
@ -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,
|
||||
)
|
||||
33
backend/package/yuxi/channel/sdk/testing/testkit.py
Normal file
33
backend/package/yuxi/channel/sdk/testing/testkit.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user