Files
MoviePilot/app/schemas/notification.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

475 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
通知渠道能力与 API 输出模型。
"""
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Optional, Set
from pydantic import BaseModel, Field
from app.schemas.types import NotificationChannel
class WechatClawBotKnownTarget(BaseModel):
"""
微信 ClawBot 已知消息目标。
"""
userid: str = Field(description="用户 ID")
username: str = Field(description="微信消息目标的显示名称")
last_active: Optional[int | float] = Field(default=None, description="消息目标最近活跃时间")
class WechatClawBotData(BaseModel):
"""
微信 ClawBot 登录状态或操作结果。
"""
success: bool = Field(description="本次微信渠道操作是否成功")
message: Optional[str] = Field(default=None, description="面向调用方的结果说明")
connected: Optional[bool] = Field(default=None, description="是否已建立有效授权或连接")
account_id: Optional[str] = Field(default=None, description="微信机器人账号标识")
qrcode: Optional[str] = Field(default=None, description="登录二维码内容")
qrcode_url: Optional[str] = Field(default=None, description="登录二维码图片地址")
qrcode_status: Optional[str] = Field(default=None, description="登录二维码当前状态")
qrcode_updated_at: Optional[int | float] = Field(default=None, description="登录二维码更新时间")
known_targets: list[WechatClawBotKnownTarget] = Field(default_factory=list, description="已知的消息发送目标")
default_target: Optional[str] = Field(default=None, description="默认消息发送目标")
base_url: Optional[str] = Field(default=None, description="服务接口的基础地址")
class ChannelCapability(Enum):
"""
渠道能力枚举。
"""
# 支持内联按钮
INLINE_BUTTONS = "inline_buttons"
# 支持菜单命令
MENU_COMMANDS = "menu_commands"
# 支持消息编辑
MESSAGE_EDITING = "message_editing"
# 支持消息删除
MESSAGE_DELETION = "message_deletion"
# 支持回调查询
CALLBACK_QUERIES = "callback_queries"
# 支持富文本
RICH_TEXT = "rich_text"
# 支持 Markdown
MARKDOWN = "markdown"
# 支持图片
IMAGES = "images"
# 支持链接
LINKS = "links"
# 支持原生语音输出
AUDIO_OUTPUT = "audio_output"
# 支持文件发送
FILE_SENDING = "file_sending"
# 支持可收口的消息处理状态提示,如 reaction 或 typing
PROCESSING_STATUS = "processing_status"
class NotificationTextFormat(Enum):
"""
通知投递链路实际支持的正文语法,独立于平台其他交互能力。
"""
# 基础 Markdown 正文
MARKDOWN = "markdown"
# 支持富文本扩展的 Markdown 正文
RICH_MARKDOWN = "rich_markdown"
# 卡片内部使用的 Markdown 正文
CARD_MARKDOWN = "card_markdown"
# Slack 风格的 mrkdwn 正文
MRKDWN = "mrkdwn"
@dataclass
class ChannelCapabilities:
"""
通知渠道的交互能力与消息长度限制。
:param channel: 适用的通知渠道
:param capabilities: 渠道支持的交互能力集合
:param max_buttons_per_row: 每行允许的最大按钮数,默认 5
:param max_button_rows: 允许的最大按钮行数,默认 10
:param max_button_text_length: 单个按钮允许的最大字符数,默认 30
:param max_message_length: 单条消息最大字符数;为 0 时不限制,用于流式输出分段
:param fallback_enabled: 不支持所需能力时是否允许降级,默认允许
:param notification_text_format: 普通通知使用的富文本语法;为空表示不支持专用富文本
:param notification_text_bytes: 通知正文的 UTF-8 字节预算;为 0 时由渠道自身控制分段
:param notification_mode_key: 限制富文本能力的配置键;为空表示不按配置模式限制
:param notification_mode_value: 配置键必须匹配的值;调用方未传配置时只返回声明的潜在能力
"""
channel: NotificationChannel
capabilities: Set[ChannelCapability]
max_buttons_per_row: int = 5
max_button_rows: int = 10
max_button_text_length: int = 30
max_message_length: int = 0
fallback_enabled: bool = True
notification_text_format: Optional[NotificationTextFormat] = None
notification_text_bytes: int = 0
notification_mode_key: Optional[str] = None
notification_mode_value: Optional[str] = None
class ChannelCapabilityManager:
"""
渠道能力管理器。
"""
_capabilities: Dict[NotificationChannel, ChannelCapabilities] = {
NotificationChannel.Telegram: ChannelCapabilities(
channel=NotificationChannel.Telegram,
notification_text_format=NotificationTextFormat.RICH_MARKDOWN,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MENU_COMMANDS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.AUDIO_OUTPUT,
ChannelCapability.FILE_SENDING,
ChannelCapability.PROCESSING_STATUS,
},
max_buttons_per_row=4,
max_button_rows=10,
max_button_text_length=30,
# Telegram 文本消息限制 4096 字符,预留空间给 MarkdownV2 转义和标题
max_message_length=3500,
),
NotificationChannel.Wechat: ChannelCapabilities(
channel=NotificationChannel.Wechat,
notification_text_format=NotificationTextFormat.MARKDOWN,
notification_text_bytes=3000,
notification_mode_key="WECHAT_MODE",
notification_mode_value="bot",
capabilities={
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.AUDIO_OUTPUT,
ChannelCapability.MENU_COMMANDS,
},
fallback_enabled=True,
),
NotificationChannel.Feishu: ChannelCapabilities(
channel=NotificationChannel.Feishu,
notification_text_format=NotificationTextFormat.CARD_MARKDOWN,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.AUDIO_OUTPUT,
ChannelCapability.FILE_SENDING,
ChannelCapability.PROCESSING_STATUS,
},
max_buttons_per_row=3,
max_button_rows=8,
max_button_text_length=20,
max_message_length=30000,
fallback_enabled=True,
),
NotificationChannel.WechatClawBot: ChannelCapabilities(
channel=NotificationChannel.WechatClawBot,
capabilities={
ChannelCapability.MARKDOWN,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.FILE_SENDING,
},
max_message_length=2800,
fallback_enabled=True,
),
NotificationChannel.Slack: ChannelCapabilities(
channel=NotificationChannel.Slack,
notification_text_format=NotificationTextFormat.MRKDWN,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.MENU_COMMANDS,
ChannelCapability.FILE_SENDING,
ChannelCapability.PROCESSING_STATUS,
},
max_buttons_per_row=3,
max_button_rows=8,
max_button_text_length=25,
# Slack 消息限制 40000 字符,预留空间给格式化
max_message_length=39000,
fallback_enabled=True,
),
NotificationChannel.Discord: ChannelCapabilities(
channel=NotificationChannel.Discord,
notification_text_format=NotificationTextFormat.MARKDOWN,
notification_text_bytes=3000,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.FILE_SENDING,
ChannelCapability.PROCESSING_STATUS,
},
max_buttons_per_row=5,
max_button_rows=5,
max_button_text_length=80,
# Discord 消息限制 2000 字符
max_message_length=1800,
fallback_enabled=True,
),
NotificationChannel.DingTalk: ChannelCapabilities(
channel=NotificationChannel.DingTalk,
notification_text_format=NotificationTextFormat.MARKDOWN,
notification_text_bytes=12000,
capabilities={
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
},
# 自定义机器人 Markdown 文本上限为 20000 字节,预留标题和图片链接空间。
max_message_length=18000,
fallback_enabled=True,
),
NotificationChannel.SynologyChat: ChannelCapabilities(
channel=NotificationChannel.SynologyChat,
notification_text_format=NotificationTextFormat.MRKDWN,
notification_text_bytes=3000,
capabilities={
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
},
fallback_enabled=True,
),
NotificationChannel.VoceChat: ChannelCapabilities(
channel=NotificationChannel.VoceChat,
notification_text_format=NotificationTextFormat.MARKDOWN,
notification_text_bytes=6000,
capabilities={
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
},
fallback_enabled=True,
),
NotificationChannel.WebPush: ChannelCapabilities(
channel=NotificationChannel.WebPush,
capabilities={ChannelCapability.LINKS},
fallback_enabled=True,
),
NotificationChannel.Web: ChannelCapabilities(
channel=NotificationChannel.Web,
capabilities={
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
},
fallback_enabled=True,
),
NotificationChannel.WebAgent: ChannelCapabilities(
channel=NotificationChannel.WebAgent,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MARKDOWN,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.AUDIO_OUTPUT,
ChannelCapability.FILE_SENDING,
},
fallback_enabled=False,
),
NotificationChannel.QQ: ChannelCapabilities(
channel=NotificationChannel.QQ,
notification_text_format=NotificationTextFormat.MARKDOWN,
notification_text_bytes=1500,
capabilities={
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS,
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.CALLBACK_QUERIES,
},
max_buttons_per_row=5,
max_button_rows=5,
max_button_text_length=30,
fallback_enabled=True,
),
}
@classmethod
def get_capabilities(cls, channel: NotificationChannel) -> Optional[ChannelCapabilities]:
"""
获取渠道能力。
:param channel: 要查询的通知渠道
:return: 渠道能力声明;未登记的渠道返回 None
"""
return cls._capabilities.get(channel)
@classmethod
def get_notification_text_format(
cls, channel: NotificationChannel, config: Optional[dict[str, object]] = None,
) -> Optional[NotificationTextFormat]:
"""
读取通知正文能力;提供实例配置时校验模式,实际服务端权限由发送渠道确认。
:param channel: 要查询的通知渠道
:param config: 渠道实例配置;省略时不校验配置模式
:return: 支持的正文语法;未知渠道或实例模式不匹配时返回 None
"""
capability = cls.get_capabilities(channel)
if not capability:
return None
if (config is not None and capability.notification_mode_key
and config.get(capability.notification_mode_key) != capability.notification_mode_value):
return None
return capability.notification_text_format
@classmethod
def supports_capability(
cls, channel: NotificationChannel, capability: ChannelCapability
) -> bool:
"""
检查渠道是否支持某项能力。
:param channel: 要查询的通知渠道
:param capability: 待检查的能力
:return: 已登记且声明支持此能力时返回 True,否则返回 False
"""
channel_caps = cls.get_capabilities(channel)
if not channel_caps:
return False
return capability in channel_caps.capabilities
@classmethod
def supports_buttons(cls, channel: NotificationChannel) -> bool:
"""
检查渠道是否支持按钮。
:param channel: 要查询的通知渠道
:return: 渠道是否声明支持该能力,未知渠道返回 False
"""
return cls.supports_capability(channel, ChannelCapability.INLINE_BUTTONS)
@classmethod
def supports_callbacks(cls, channel: NotificationChannel) -> bool:
"""
检查渠道是否支持回调。
:param channel: 要查询的通知渠道
:return: 渠道是否声明支持该能力,未知渠道返回 False
"""
return cls.supports_capability(channel, ChannelCapability.CALLBACK_QUERIES)
@classmethod
def supports_editing(cls, channel: NotificationChannel) -> bool:
"""
检查渠道是否支持消息编辑。
:param channel: 要查询的通知渠道
:return: 渠道是否声明支持该能力,未知渠道返回 False
"""
return cls.supports_capability(channel, ChannelCapability.MESSAGE_EDITING)
@classmethod
def supports_markdown(cls, channel: NotificationChannel) -> bool:
"""
检查渠道是否支持 Markdown。
:param channel: 要查询的通知渠道
:return: 渠道是否声明支持该能力,未知渠道返回 False
"""
return cls.supports_capability(channel, ChannelCapability.MARKDOWN)
@classmethod
def supports_deletion(cls, channel: NotificationChannel) -> bool:
"""
检查渠道是否支持消息删除。
:param channel: 要查询的通知渠道
:return: 渠道是否声明支持该能力,未知渠道返回 False
"""
return cls.supports_capability(channel, ChannelCapability.MESSAGE_DELETION)
@classmethod
def get_max_buttons_per_row(cls, channel: NotificationChannel) -> int:
"""
获取每行最大按钮数。
:param channel: 要查询的通知渠道
:return: 每行最大按钮数;未知渠道回退为 2
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_buttons_per_row if channel_caps else 2
@classmethod
def get_max_button_rows(cls, channel: NotificationChannel) -> int:
"""
获取最大按钮行数。
:param channel: 要查询的通知渠道
:return: 最大按钮行数;未知渠道回退为 5
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_button_rows if channel_caps else 5
@classmethod
def get_max_button_text_length(cls, channel: NotificationChannel) -> int:
"""
获取按钮文本最大长度。
:param channel: 要查询的通知渠道
:return: 按钮文本最大字符数;未知渠道回退为 20
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_button_text_length if channel_caps else 20
@classmethod
def get_max_message_length(cls, channel: NotificationChannel) -> int:
"""
获取单条消息最大长度(0 表示不限制)。
:param channel: 要查询的通知渠道
:return: 单条消息最大字符数;未知渠道或不限制时返回 0
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.max_message_length if channel_caps else 0
@classmethod
def should_use_fallback(cls, channel: NotificationChannel) -> bool:
"""
是否应该使用降级策略。
:param channel: 要查询的通知渠道
:return: 是否允许能力降级;未知渠道默认允许
"""
channel_caps = cls.get_capabilities(channel)
return channel_caps.fallback_enabled if channel_caps else True