from enum import Enum from typing import Optional, Union, List, Dict, Any from pydantic import BaseModel, Field, field_validator from app.schemas.common import JsonData from app.schemas.types import ContentType, MessageType, NotificationChannel class MessageClearScope(str, Enum): """ 通知中心清理范围。 """ # 全部消息 All = "all" # 系统消息 System = "system" # 媒体消息 Media = "media" class MessageClearBefore(BaseModel): """ 通知中心按范围记录的清理时间。 """ # 全部消息清理时间 all: int = 0 # 系统消息清理时间 system: int = 0 # 媒体消息清理时间 media: int = 0 class MessageResponse(BaseModel): """ 消息发送响应,包含消息ID等信息用于后续编辑 """ # 消息ID message_id: Optional[Union[str, int]] = None # 聊天ID chat_id: Optional[Union[str, int]] = None # 消息渠道 channel: Optional[NotificationChannel] = None # 消息来源 source: Optional[str] = None # 渠道自定义上下文(如飞书流式卡片 card_id/element_id/sequence) metadata: Optional[Dict[str, JsonData]] = None # 是否发送成功 success: bool = False class MessageHistoryItem(BaseModel): """ 通知历史记录。 """ # 消息ID id: Optional[int] = None # 消息渠道 channel: Optional[str] = None # 消息来源 source: Optional[str] = None # 消息类型 mtype: Optional[str] = None # 标题 title: Optional[str] = None # 文本内容 text: Optional[str] = None # 图片 image: Optional[str] = None # 链接 link: Optional[str] = None # 用户ID userid: Optional[str] = None # 登记时间 reg_time: Optional[str] = None # 消息方向:0-接收消息,1-发送消息 action: Optional[int] = None # 附件json note: Optional[JsonData] = None class WebMessageItem(MessageHistoryItem): """Web 消息历史记录。""" class MessageClearData(BaseModel): """消息中心各范围的清理时间。""" clear_before: MessageClearBefore = Field(description="各范围清理时间") class IncomingMessage(BaseModel): """ 外来消息 """ class MessageImage(BaseModel): """ 外来消息图片 """ ref: str name: Optional[str] = None mime_type: Optional[str] = None size: Optional[int] = None @classmethod def from_value(cls, value: Any) -> Optional["IncomingMessage.MessageImage"]: if value is None: return None if isinstance(value, cls): return value if isinstance(value, str): return cls(ref=value) if isinstance(value, dict): ref = ( value.get("ref") or value.get("url") or value.get("image_url") or value.get("file_url") ) if not ref: return None size = value.get("size") try: size = int(size) if size is not None else None except (TypeError, ValueError): size = None return cls( ref=ref, name=value.get("name") or value.get("filename"), mime_type=value.get("mime_type") or value.get("content_type"), size=size, ) return None @classmethod def normalize_list( cls, values: Optional[Any] ) -> Optional[List["IncomingMessage.MessageImage"]]: if not values: return None if not isinstance(values, list): values = [values] normalized = [] for value in values: item = cls.from_value(value) if item: normalized.append(item) return normalized or None class MessageAttachment(BaseModel): """ 外来消息附件(非图片/非语音) """ ref: str name: Optional[str] = None mime_type: Optional[str] = None size: Optional[int] = None # 用户ID userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None # 渠道适配器依据稳定用户 ID、管理员名单及渠道主用户 ID 生成的授权事实 is_channel_admin: Optional[bool] = None # 消息渠道 channel: Optional[NotificationChannel] = None # 来源(渠道名称) source: Optional[str] = None # 消息体 text: Optional[str] = None # 时间 date: Optional[str] = None # 消息方向 action: Optional[int] = 0 # 是否为回调消息 is_callback: Optional[bool] = False # 回调数据 callback_data: Optional[str] = None # 消息ID(用于回调时定位原消息) message_id: Optional[Union[str, int]] = None # 聊天ID(用于回调时定位聊天) chat_id: Optional[str] = None # 回复目标消息ID(用于 ForceReply 等回复场景) reply_to_message_id: Optional[Union[str, int]] = None # 完整的回调查询信息(原始数据) callback_query: Optional[Dict] = None # 图片列表(图片URL或file_id) images: Optional[List[MessageImage]] = None # 语音/音频引用列表 audio_refs: Optional[List[str]] = None # 文件附件列表 files: Optional[List[MessageAttachment]] = None # 结构化按钮回调数据(优先于 CALLBACK: 文本前缀) callback_data: Optional[str] = None @field_validator("images", mode="before") @classmethod def _normalize_images( cls, value: Any ) -> Optional[List["IncomingMessage.MessageImage"]]: return cls.MessageImage.normalize_list(value) def to_dict(self): """ 转换为字典 """ items = self.model_dump() for k, v in items.items(): if isinstance(v, NotificationChannel): items[k] = v.value return items class Message(BaseModel): """ 消息 """ # 消息渠道 channel: Optional[NotificationChannel] = None # 消息来源 source: Optional[str] = None # 消息类型 mtype: Optional[MessageType] = None # 内容类型 ctype: Optional[ContentType] = None # 标题 title: Optional[str] = None # 文本内容 text: Optional[str] = None # 图片 image: Optional[str] = None # 语音文件路径 voice_path: Optional[str] = None # 本地文件路径 file_path: Optional[str] = None # 发送时展示的文件名 file_name: Optional[str] = None # 语音消息附带说明文字 voice_caption: Optional[str] = None # 链接 link: Optional[str] = None # 用户ID userid: Optional[Union[str, int]] = None # 用户名称 username: Optional[Union[str, int]] = None # 时间 date: Optional[str] = None # 消息方向 action: Optional[int] = 1 # 消息目标用户ID字典,未指定用户ID时使用 targets: Optional[dict] = None # 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]] buttons: Optional[List[List[dict]]] = None # Telegram ForceReply 回复标记 force_reply: bool = False # 原消息ID,用于编辑消息 original_message_id: Optional[Union[str, int]] = None # 原消息的聊天ID,用于编辑消息 original_chat_id: Optional[str] = None # 是否必须按用户身份投递到私聊,禁止回退原会话或最近会话映射 private_delivery: bool = False # 是否禁用链接预览(仅Telegram支持) disable_web_page_preview: Optional[bool] = None # 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain,飞书直发支持 plain parse_mode: Optional[str] = None # Telegram Rich Message 完整 Markdown 正文;其他渠道可使用 text 作为回退 rich_message: Optional[str] = None # 是否写入消息历史 save_history: bool = True def to_dict(self): """ 转换为字典 """ items = self.model_dump() for k, v in items.items(): if isinstance(v, NotificationChannel) or isinstance(v, MessageType): items[k] = v.value return items class NotificationSwitch(BaseModel): """ 消息开关 """ # 消息类型 mtype: Optional[str] = None # 微信开关 wechat: Optional[bool] = False # 飞书开关 feishu: Optional[bool] = False # TG开关 telegram: Optional[bool] = False # Slack开关 slack: Optional[bool] = False # SynologyChat开关 synologychat: Optional[bool] = False # VoceChat开关 vocechat: Optional[bool] = False # WebPush开关 webpush: Optional[bool] = False # QQ开关 qq: Optional[bool] = False class Subscription(BaseModel): """ 客户端消息订阅 """ endpoint: Optional[str] = None keys: Optional[dict[str, str]] = Field(default_factory=dict) class SubscriptionMessage(BaseModel): """ 客户端订阅消息体 """ title: Optional[str] = None body: Optional[str] = None icon: Optional[str] = None url: Optional[str] = None data: Optional[dict[str, JsonData]] = Field(default_factory=dict) class AgentWebChatRequest(BaseModel): """ Web 智能助手对话请求。 """ class AgentWebChatFile(BaseModel): """ Web 智能助手输入附件。 """ ref: str = Field(..., min_length=1) name: Optional[str] = Field(None) mime_type: Optional[str] = Field(None) size: Optional[int] = Field(None) local_path: Optional[str] = Field(None) status: Optional[str] = Field(None) # 用户本轮输入 text: str = Field(default="") # 展示历史中记录的用户可读文本;为空时使用 text display_text: Optional[str] = Field(None) # 前端会话标识,相同标识复用同一段 Agent 记忆 session_id: Optional[str] = Field(None) # 图片 URL 或 data URL 列表 images: Optional[List[str]] = Field(default_factory=list) # 语音/音频引用列表 audio_refs: Optional[List[str]] = Field(default_factory=list) # 文件附件列表 files: Optional[List[AgentWebChatFile]] = Field(default_factory=list) # 用户通过按钮选择时的完整选择快照 choice_selection: Optional[Dict[str, JsonData]] = Field(default=None) # WebAgent 按钮回调关联的原消息 ID,用于传统交互原地编辑卡片 original_message_id: Optional[Union[str, int]] = Field(default=None) # WebAgent 按钮回调关联的原聊天 ID,用于传统交互原地编辑卡片 original_chat_id: Optional[Union[str, int]] = Field(default=None) # 是否在展示历史中记录本轮用户消息 echo_user: bool = Field(default=True) class AgentWebChoiceRequest(BaseModel): """ Web 智能助手按钮选择请求。 """ # 前端会话标识,用于保持与原对话窗口的关联 session_id: Optional[str] = Field(None) # Agent 工具生成的按钮回调数据 callback_data: str = Field(..., min_length=1) # WebAgent 原助手消息 ID,用于传统按钮回调原地编辑 original_message_id: Optional[Union[str, int]] = Field(default=None) # WebAgent 原聊天 ID,用于传统按钮回调原地编辑 original_chat_id: Optional[Union[str, int]] = Field(default=None)