本次提交对渠道模块进行了全面升级,包含以下核心改进: 1. 新增二维码登录相关协议方法,完善登录流程 2. 优化配置监听逻辑,增加渠道运行状态前置校验 3. 重构动作注册机制,支持动态注册渠道动作并新增批量操作能力 4. 扩展渠道能力模型,新增广播、文件传输等支持 5. 优化适配器加载路径,新增元宝适配器支持 6. 新增凭证过期检查与告警能力,完善运维监控 7. 重构统计收集器,支持多维度渠道统计数据 8. 优化消息路由策略,新增策略缓存与安全处理逻辑 9. 重构基础适配器,新增凭证管理工具方法 10. 完善状态存储功能,支持凭证数据管理与批量清理 11. 重构渠道管理器,新增配置校验、动态渠道管理、限流能力 12. 优化健康检查与状态上报逻辑,完善审计日志与异常处理
315 lines
10 KiB
Python
315 lines
10 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_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:
|
|
existing = cls._channel_actions.get(channel_id, {})
|
|
existing.update(actions)
|
|
cls._channel_actions[channel_id] = existing
|
|
|
|
@classmethod
|
|
def deregister(cls, channel_id: str) -> None:
|
|
cls._capabilities.pop(channel_id, None)
|
|
cls._channel_actions.pop(channel_id, None)
|
|
|
|
@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"),
|
|
broadcast=_overridden("broadcast_message"),
|
|
file_transfer=_overridden("download_file"),
|
|
group_management=_overridden("kick_member"),
|
|
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
|