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