mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-13 09:10:45 +08:00
469 lines
15 KiB
Python
469 lines
15 KiB
Python
from dataclasses import asdict as _asdict
|
||
from dataclasses import dataclass as _dataclass
|
||
from dataclasses import field as _field
|
||
from enum import Enum
|
||
from typing import Any, Dict, List, Optional, Union
|
||
|
||
from pydantic import BaseModel, Field, field_validator
|
||
from pydantic import PrivateAttr as _PrivateAttr
|
||
|
||
from app.schemas.common import JsonData
|
||
from app.schemas.notification import ChannelCapabilityManager as _ChannelCapabilityManager
|
||
from app.schemas.notification import NotificationTextFormat as _NotificationTextFormat
|
||
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
|
||
|
||
|
||
@_dataclass
|
||
class NotificationBlock:
|
||
"""通知段落,内容均为普通文本;单独标题开启分节,节中的标题/正文对表示条目。"""
|
||
|
||
title: Optional[str] = None
|
||
text: Optional[str] = None
|
||
|
||
|
||
@_dataclass
|
||
class NotificationContent:
|
||
"""通用结构化通知,宿主根据渠道能力渲染,不承载签到等业务规则。"""
|
||
|
||
title: Optional[str] = None
|
||
blocks: List[NotificationBlock] = _field(default_factory=list)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""返回普通的标题和段落数据,不包含渠道配置或格式标记。"""
|
||
return _asdict(self)
|
||
|
||
def render(self, text_format: _NotificationTextFormat) -> str:
|
||
"""按声明的语法生成正文,渠道标题、卡片和按钮仍由各适配器添加。"""
|
||
# 按需加载文本转换,保持普通消息模型导入轻量。
|
||
from app.foundation.text import render_notification as _render_notification
|
||
|
||
return _render_notification(self.title, [_asdict(block) for block in self.blocks], text_format.value)
|
||
|
||
|
||
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
|
||
# 渠道无关的结构化通知,宿主按能力渲染;text 始终保留为纯文本回退。
|
||
rich_content: Optional[NotificationContent] = None
|
||
# 是否写入消息历史
|
||
save_history: bool = True
|
||
|
||
# 通知隔离路由范围,仅在运行时传递给通知模块,不写入消息数据
|
||
_notification_route_scope: Optional[str] = _PrivateAttr(default=None)
|
||
|
||
def channel_text(self, channel: NotificationChannel, config: Optional[dict[str, object]] = None) -> Optional[str]:
|
||
"""仅返回能力声明和实例模式允许的专用正文;缺少能力时保留原始通知。"""
|
||
text_format = _ChannelCapabilityManager.get_notification_text_format(channel, config)
|
||
if text_format is None or self.rich_content is None:
|
||
return None
|
||
return self.rich_content.render(text_format)
|
||
|
||
def channel_text_chunks(self, channel: NotificationChannel, max_bytes: Optional[int] = None) -> List[str]:
|
||
"""按段落读取有界渠道正文;缺失或单段超限时由渠道继续使用原 text。"""
|
||
text = self.channel_text(channel)
|
||
if not text:
|
||
return []
|
||
if max_bytes is None:
|
||
capability = _ChannelCapabilityManager.get_capabilities(channel)
|
||
max_bytes = capability.notification_text_bytes if capability else 0
|
||
if max_bytes <= 0:
|
||
return [text]
|
||
paragraphs = text.split("\n\n")
|
||
if any(len(part.encode("utf-8")) > max_bytes for part in paragraphs):
|
||
return []
|
||
chunks = []
|
||
current: List[str] = []
|
||
for part in paragraphs:
|
||
candidate = "\n\n".join(current + [part])
|
||
if current and len(candidate.encode("utf-8")) > max_bytes:
|
||
chunks.append("\n\n".join(current))
|
||
current = []
|
||
current.append(part)
|
||
chunks.append("\n\n".join(current))
|
||
return chunks
|
||
|
||
def set_notification_route_scope(self, scope: Optional[str]) -> None:
|
||
"""设置运行时通知路由范围,支持管理员默认目标和用户个人目标的区分。"""
|
||
self._notification_route_scope = scope
|
||
|
||
@property
|
||
def notification_route_scope(self) -> Optional[str]:
|
||
"""返回运行时通知路由范围,不影响消息序列化和历史记录。"""
|
||
return self._notification_route_scope
|
||
|
||
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)
|