from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime from enum import Flag, StrEnum, auto from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from yuxi.channel.exceptions import ChannelErrorClassification if TYPE_CHECKING: from yuxi.channel.capabilities import CapabilityMatrix from yuxi.channel.security.models import DmPolicy, GroupPolicy, RateLimitPolicy from yuxi.channel.transport.protocol import Transport, TransportState from yuxi.channel.transport.qr_login import QRLoginSession __all__ = [ "BindingConversationRef", "BindingRoute", "ChannelCapability", "ChannelErrorClassification", "ChannelHealthStatus", "ChannelMeta", "ChannelPlugin", "DeliveryCapabilities", "DeliveryMode", "DmPolicy", "GroupPolicy", "InboundMedia", "InboundMessage", "InboundRequest", "OutboundMessage", "QRLoginSession", "RateLimitPolicy", "SessionConversationRef", "Transport", "TransportState", "TransportType", ] @runtime_checkable class InboundRequest(Protocol): """入站 HTTP 请求抽象,兼容 FastAPI Request 与 FakeRequest。""" state: Any headers: Mapping[str, str] query_params: Mapping[str, str] path_params: dict[str, str] async def json(self) -> Any: ... async def body(self) -> bytes: ... class ChannelCapability(Flag): TEXT = auto() MARKDOWN = auto() IMAGE = auto() FILE = auto() AUDIO = auto() VIDEO = auto() INTERACTIVE = auto() REACTION = auto() THREAD = auto() EDIT = auto() UNSEND = auto() POLL = auto() STREAMING = auto() class DeliveryMode(StrEnum): DIRECT = "direct" GATEWAY = "gateway" HYBRID = "hybrid" class TransportType(StrEnum): WEBHOOK = "webhook" WEBSOCKET = "websocket" POLLING = "polling" @dataclass class ChannelMeta: channel_type: str display_name: str aliases: list[str] = field(default_factory=list) capabilities: ChannelCapability = ChannelCapability.TEXT capability_matrix: CapabilityMatrix | None = field(default=None) delivery_mode: DeliveryMode = DeliveryMode.DIRECT transport_type: TransportType = TransportType.WEBHOOK config_schema: dict = field(default_factory=dict) ui_hints: dict = field(default_factory=dict) sort_weight: int = 0 icon: str | None = None description: str = "" def __post_init__(self) -> None: if self.capability_matrix is None: from yuxi.channel.capabilities import CapabilityMatrix self.capability_matrix = CapabilityMatrix.from_channel_capability(self.capabilities) @dataclass class InboundMedia: media_type: str url: str | None = None file_key: str | None = None file_name: str | None = None size: int | None = None mime_type: str | None = None file_content: bytes | None = None @dataclass class InboundMessage: channel_type: str account_id: str channel_message_id: str | None = None sender_id: str | None = None sender_name: str | None = None peer_id: str | None = None session_key: str | None = None chat_type: str | None = None thread_id: str | None = None content: str = "" content_type: str = "text" media: list[InboundMedia] = field(default_factory=list) timestamp: datetime | None = None raw_event: dict = field(default_factory=dict) is_at_bot: bool = False mentioned_user_ids: list[str] = field(default_factory=list) is_scan_event: bool = False @dataclass class OutboundMessage: content: str content_type: str = "text" media: list[dict] = field(default_factory=list) reply_to_channel_message_id: str | None = None thread_id: str | None = None extra: dict = field(default_factory=dict) @dataclass class SessionConversationRef: session_key: str chat_type: str | None = None channel_sender_id: str | None = None parent_conversation_candidates: list[str] = field(default_factory=list) channel_metadata: dict = field(default_factory=dict) @dataclass class BindingRoute: agent_id: str | None session_key: str matched_by: str binding_rule_hash: str | None = None @dataclass class BindingConversationRef: session_key: str channel_type: str account_id: str chat_type: str | None = None peer_id: str | None = None thread_id: str | None = None binding_rule_hash: str = "" @dataclass class ChannelHealthStatus: healthy: bool state: str enabled: bool last_connected_at: datetime | None = None last_message_at: datetime | None = None last_error: str | None = None reconnect_attempts: int = 0 def to_dict(self) -> dict[str, Any]: """将健康状态转换为可 JSON 序列化的字典。""" return { "healthy": self.healthy, "state": self.state, "enabled": self.enabled, "last_connected_at": self.last_connected_at.isoformat() if self.last_connected_at else None, "last_message_at": self.last_message_at.isoformat() if self.last_message_at else None, "last_error": self.last_error, "reconnect_attempts": self.reconnect_attempts, } @dataclass class DeliveryCapabilities: max_text_length: int = 4000 supports_markdown: bool = False supports_interactive: bool = False supports_media: bool = False # 放在数据模型定义之后,按子模块分别导入,避免 ports 包 __init__ 循环导入 from yuxi.channel.ports._config import ConfigPort # noqa: E402 from yuxi.channel.ports._inbound import InboundPort # noqa: E402 from yuxi.channel.ports._lifecycle import LifecyclePort # noqa: E402 from yuxi.channel.ports._meta import MetaPort # noqa: E402 from yuxi.channel.ports._outbound import OutboundPort # noqa: E402 from yuxi.channel.ports._security import SecurityPort # noqa: E402 from yuxi.channel.ports._session import SessionPort # noqa: E402 from yuxi.channel.ports._status import StatusPort # noqa: E402 from yuxi.channel.ports._transport import TransportPort # noqa: E402 @runtime_checkable class ChannelPlugin( MetaPort, ConfigPort, InboundPort, OutboundPort, TransportPort, SecurityPort, StatusPort, LifecyclePort, SessionPort, Protocol, ): """兼容旧代码的组合协议别名,继承所有核心 Port。"""