ForcePilot/backend/package/yuxi/channels/message_actions.py
Kris 7a972055b7 feat: 新增渠道服务基础框架与核心工具类
新增大量渠道适配器相关的协议、策略、工具类与基础设施代码,包括:
1.  多协议定义:认证、消息、配置、网关等核心接口
2.  策略模块:上下文、群聊、去重、防抖等业务策略
3.  工具集:重试、去重、文本分块、消息格式化等SDK工具
4.  基础设施:外部进程管理、事件广播、熔断机制等
5.  账户与管道系统:账户管理、消息处理管道实现
6.  运行时服务:状态收集、维护任务、日志等后台服务
2026-05-12 00:53:57 +08:00

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