新增大量渠道适配器相关的协议、策略、工具类与基础设施代码,包括: 1. 多协议定义:认证、消息、配置、网关等核心接口 2. 策略模块:上下文、群聊、去重、防抖等业务策略 3. 工具集:重试、去重、文本分块、消息格式化等SDK工具 4. 基础设施:外部进程管理、事件广播、熔断机制等 5. 账户与管道系统:账户管理、消息处理管道实现 6. 运行时服务:状态收集、维护任务、日志等后台服务
306 lines
9.6 KiB
Python
306 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from typing import ClassVar
|
|
|
|
from yuxi.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.capabilities import ChannelCapabilities
|
|
|
|
|
|
class MessageAction(StrEnum):
|
|
"""58 种标准消息动作 — 基于 WeChat 枚举扩展"""
|
|
|
|
SEND = "send"
|
|
BROADCAST = "broadcast"
|
|
REPLY = "reply"
|
|
EDIT = "edit"
|
|
UNSEND = "unsend"
|
|
DELETE = "delete"
|
|
READ = "read"
|
|
|
|
CREATE_POLL = "create_poll"
|
|
CLOSE_POLL = "close_poll"
|
|
|
|
REACT = "react"
|
|
LIST_REACTIONS = "list_reactions"
|
|
|
|
SEND_WITH_EFFECT = "send_with_effect"
|
|
|
|
RENAME_GROUP = "rename_group"
|
|
SET_GROUP_ICON = "set_group_icon"
|
|
DELETE_CHAT_PHOTO = "delete_chat_photo"
|
|
SET_CHAT_DESCRIPTION = "set_chat_description"
|
|
ADD_PARTICIPANT = "add_participant"
|
|
REMOVE_PARTICIPANT = "remove_participant"
|
|
LEAVE_GROUP = "leave_group"
|
|
|
|
PIN = "pin"
|
|
UNPIN = "unpin"
|
|
LIST_PINS = "list_pins"
|
|
|
|
PERMISSIONS = "permissions"
|
|
KICK = "kick"
|
|
BAN = "ban"
|
|
MUTE = "mute"
|
|
|
|
THREAD_CREATE = "thread_create"
|
|
THREAD_LIST = "thread_list"
|
|
THREAD_REPLY = "thread_reply"
|
|
|
|
SEARCH = "search"
|
|
|
|
STICKER = "sticker"
|
|
STICKER_SEARCH = "sticker_search"
|
|
STICKER_UPLOAD = "sticker_upload"
|
|
EMOJI_LIST = "emoji_list"
|
|
EMOJI_UPLOAD = "emoji_upload"
|
|
|
|
MEMBER_INFO = "member_info"
|
|
ROLE_INFO = "role_info"
|
|
ROLE_ADD = "role_add"
|
|
ROLE_REMOVE = "role_remove"
|
|
|
|
CHANNEL_INFO = "channel_info"
|
|
CHANNEL_LIST = "channel_list"
|
|
CHANNEL_CREATE = "channel_create"
|
|
CHANNEL_EDIT = "channel_edit"
|
|
CHANNEL_DELETE = "channel_delete"
|
|
CHANNEL_MOVE = "channel_move"
|
|
|
|
CATEGORY_CREATE = "category_create"
|
|
CATEGORY_EDIT = "category_edit"
|
|
CATEGORY_DELETE = "category_delete"
|
|
|
|
TOPIC_CREATE = "topic_create"
|
|
TOPIC_EDIT = "topic_edit"
|
|
|
|
VOICE_STATUS = "voice_status"
|
|
|
|
EVENT_LIST = "event_list"
|
|
EVENT_CREATE = "event_create"
|
|
|
|
SET_PROFILE = "set_profile"
|
|
SET_PRESENCE = "set_presence"
|
|
|
|
DOWNLOAD_FILE = "download_file"
|
|
UPLOAD_FILE = "upload_file"
|
|
SEND_ATTACHMENT = "send_attachment"
|
|
|
|
SEND_MEDIA = "send_media"
|
|
STREAM = "stream"
|
|
SEND_EPHEMERAL = "send_ephemeral"
|
|
CREATE_THREAD = "create_thread"
|
|
REPLY_THREAD = "reply_thread"
|
|
|
|
|
|
class ActionStatus(StrEnum):
|
|
SUPPORTED = "supported"
|
|
UNSUPPORTED = "unsupported"
|
|
PARTIAL = "partial"
|
|
|
|
|
|
@dataclass
|
|
class ActionDeclaration:
|
|
action: MessageAction
|
|
status: ActionStatus
|
|
reason: str = ""
|
|
impl: str = ""
|
|
|
|
|
|
class ActionRegistry:
|
|
_capabilities: ClassVar[dict[str, ChannelCapabilities]] = {}
|
|
_channel_actions: ClassVar[dict[str, dict[MessageAction, ActionDeclaration]]] = {}
|
|
|
|
@classmethod
|
|
def register(cls, channel_id: str, capabilities: ChannelCapabilities) -> None:
|
|
cls._capabilities[channel_id] = capabilities
|
|
|
|
@classmethod
|
|
def register_adapter(cls, adapter_cls: type[BaseChannelAdapter]) -> None:
|
|
caps = getattr(adapter_cls, "capabilities", None)
|
|
from yuxi.channels.capabilities import CAPS_SIMPLE_TEXT
|
|
|
|
if caps is CAPS_SIMPLE_TEXT or not isinstance(caps, ChannelCapabilities):
|
|
caps = cls.derive_from_adapter_cls(adapter_cls)
|
|
cid = adapter_cls.channel_id
|
|
cls._capabilities[cid] = caps
|
|
|
|
@classmethod
|
|
def register_adapters(cls, adapters: list[type[BaseChannelAdapter]]) -> None:
|
|
for adapter_cls in adapters:
|
|
cls.register_adapter(adapter_cls)
|
|
|
|
@classmethod
|
|
def register_channel_actions(
|
|
cls,
|
|
channel_id: str,
|
|
actions: dict[MessageAction, ActionDeclaration],
|
|
) -> None:
|
|
cls._channel_actions[channel_id] = actions
|
|
|
|
@classmethod
|
|
def derive_from_adapter_cls(cls, adapter_cls: type[BaseChannelAdapter]) -> ChannelCapabilities:
|
|
base = BaseChannelAdapter
|
|
|
|
def _overridden(method_name: str) -> bool:
|
|
adapter_method = getattr(adapter_cls, method_name, None)
|
|
base_method = getattr(base, method_name, None)
|
|
if adapter_method is None:
|
|
return False
|
|
if base_method is None:
|
|
return True
|
|
return adapter_method is not base_method
|
|
|
|
return ChannelCapabilities(
|
|
chat_types=["direct", "group"],
|
|
media=_overridden("send_media"),
|
|
edit=_overridden("edit_message"),
|
|
unsend=_overridden("delete_message"),
|
|
reactions=_overridden("send_reaction"),
|
|
pin=_overridden("pin_message"),
|
|
unpin=_overridden("unpin_message"),
|
|
list_pins=_overridden("list_pins"),
|
|
send_ephemeral=_overridden("send_ephemeral"),
|
|
supports_streaming=getattr(adapter_cls, "supports_streaming", False),
|
|
streaming_modes=list(getattr(adapter_cls, "streaming_modes", ["off"])),
|
|
supports_markdown=getattr(adapter_cls, "supports_markdown", False),
|
|
max_media_size_mb=getattr(adapter_cls, "max_media_size_mb", 100),
|
|
text_chunk_limit=getattr(adapter_cls, "text_chunk_limit", 4096),
|
|
)
|
|
|
|
@classmethod
|
|
def get(cls, channel_id: str) -> ChannelCapabilities | None:
|
|
return cls._capabilities.get(channel_id)
|
|
|
|
@classmethod
|
|
def supports(cls, channel_id: str, action: str) -> bool:
|
|
caps = cls._capabilities.get(channel_id)
|
|
if caps is None:
|
|
return False
|
|
return action in caps.supported_actions()
|
|
|
|
@classmethod
|
|
def find_channels_supporting(cls, action: str) -> list[str]:
|
|
return [cid for cid, caps in cls._capabilities.items() if action in caps.supported_actions()]
|
|
|
|
@classmethod
|
|
def list_all(cls) -> dict[str, ChannelCapabilities]:
|
|
return dict(cls._capabilities)
|
|
|
|
@classmethod
|
|
def is_supported(cls, channel_id: str, action: MessageAction) -> bool:
|
|
declarations = cls._channel_actions.get(channel_id, {})
|
|
decl = declarations.get(action)
|
|
if decl is None:
|
|
return cls.supports(channel_id, action.value)
|
|
return decl.status == ActionStatus.SUPPORTED
|
|
|
|
@classmethod
|
|
def list_supported_actions(cls, channel_id: str) -> list[MessageAction]:
|
|
declarations = cls._channel_actions.get(channel_id, {})
|
|
if declarations:
|
|
return [a for a, d in declarations.items() if d.status == ActionStatus.SUPPORTED]
|
|
caps = cls._capabilities.get(channel_id)
|
|
if caps is None:
|
|
return []
|
|
result: list[MessageAction] = []
|
|
for action_name in caps.supported_actions():
|
|
try:
|
|
result.append(MessageAction(action_name))
|
|
except ValueError:
|
|
pass
|
|
return result
|
|
|
|
@classmethod
|
|
def list_channels_for_action(cls, action: MessageAction) -> list[str]:
|
|
channels: list[str] = []
|
|
for cid, declarations in cls._channel_actions.items():
|
|
decl = declarations.get(action)
|
|
if decl is not None and decl.status == ActionStatus.SUPPORTED:
|
|
channels.append(cid)
|
|
for cid in cls.find_channels_supporting(action.value):
|
|
if cid not in channels:
|
|
channels.append(cid)
|
|
return channels
|
|
|
|
@classmethod
|
|
def get_action_declaration(cls, channel_id: str, action: MessageAction) -> ActionDeclaration | None:
|
|
declarations = cls._channel_actions.get(channel_id, {})
|
|
return declarations.get(action)
|
|
|
|
@classmethod
|
|
def list_all_declarations(cls, channel_id: str) -> dict[MessageAction, ActionDeclaration]:
|
|
return dict(cls._channel_actions.get(channel_id, {}))
|
|
|
|
@classmethod
|
|
def clear(cls) -> None:
|
|
cls._capabilities.clear()
|
|
cls._channel_actions.clear()
|
|
|
|
|
|
class ChannelNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class ActionNotSupportedError(Exception):
|
|
def __init__(self, action: str, channel_id: str):
|
|
super().__init__(f"Action '{action}' not supported by channel '{channel_id}'")
|
|
|
|
|
|
class ActionRouter:
|
|
def __init__(self, channel_manager):
|
|
from yuxi.channels.manager import ChannelManager
|
|
|
|
self._manager: ChannelManager = channel_manager
|
|
|
|
async def dispatch(
|
|
self,
|
|
action: str,
|
|
chat_id: str,
|
|
msg_id: str,
|
|
args: dict | None = None,
|
|
channel_id: str | None = None,
|
|
session_key: str | None = None,
|
|
):
|
|
from yuxi.channels.protocols.actions import ActionContext
|
|
|
|
if channel_id:
|
|
adapter = self._manager._adapters.get(channel_id)
|
|
if not adapter:
|
|
raise ChannelNotFoundError(f"channel_id={channel_id}")
|
|
else:
|
|
adapter = self._find_adapter_by_chat_id(chat_id)
|
|
if not adapter:
|
|
raise ChannelNotFoundError(str(chat_id))
|
|
|
|
resolved_cid = adapter.channel_id if channel_id is None else channel_id
|
|
|
|
ctx = ActionContext(
|
|
action=action,
|
|
channel_id=resolved_cid,
|
|
chat_id=chat_id,
|
|
msg_id=msg_id,
|
|
args=args or {},
|
|
session_key=session_key,
|
|
)
|
|
|
|
if hasattr(adapter, "handle_action"):
|
|
return await adapter.handle_action(ctx)
|
|
|
|
raise ActionNotSupportedError(action, resolved_cid)
|
|
|
|
def supports_action(self, channel_id: str, action: str) -> bool:
|
|
adapter = self._manager._adapters.get(channel_id)
|
|
if adapter and hasattr(adapter, "supports_action"):
|
|
return adapter.supports_action(action)
|
|
return False
|
|
|
|
def _find_adapter_by_chat_id(self, chat_id: str):
|
|
for adapter in self._manager._adapters.values():
|
|
if hasattr(adapter, "_active_chats") and chat_id in adapter._active_chats:
|
|
return adapter
|
|
for adapter in self._manager._adapters.values():
|
|
return adapter
|
|
return None
|