feat(channel-sdk): 新增完整的渠道SDK工具链

本提交新增了全渠道SDK核心模块:
1.  异步锁、目标解析、动作调度等基础工具
2.  消息动作注册与统一调度系统
3.  测试套件与契约测试框架
4.  完整的目标解析流水线与工具函数
5.  资源依赖注入与生命周期管理
This commit is contained in:
Kris 2026-05-21 10:29:12 +08:00
parent d52e9b518d
commit b438af3ba8
24 changed files with 2020 additions and 0 deletions

View 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",
]

View 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",
]

View 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()]

View 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,
}

View 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 {},
}

View 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, ...] = ()

View 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

View 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)

View 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()

View 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())

View 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",
]

View 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

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

View 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", "")))

View 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

View 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]

View 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 = ""

View 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())

View 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",
]

View 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

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

View 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"

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

View 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