mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
427 lines
16 KiB
Python
427 lines
16 KiB
Python
"""
|
||
VoceChat 通知渠道的消息解析、管理员校验与附件下载入口。
|
||
"""
|
||
|
||
import json
|
||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||
from urllib.parse import quote, unquote
|
||
|
||
from app.application.messaging.channel.admin import (
|
||
matches_channel_admin,
|
||
register_channel_admin_resolver,
|
||
resolve_config_principal_ids,
|
||
)
|
||
from app.domain.context import Context, MediaInfo
|
||
from app.modules._base.notification import _MessageChannelModuleBase
|
||
from app.modules.vocechat.vocechat import VoceChat
|
||
from app.runtime.log import logger
|
||
from app.schemas.message import IncomingMessage, Message
|
||
from app.schemas.types import ModuleType, NotificationChannel
|
||
|
||
register_channel_admin_resolver(
|
||
NotificationChannel.VoceChat,
|
||
lambda config: resolve_config_principal_ids(config, "VOCECHAT_ADMINS"),
|
||
)
|
||
|
||
|
||
class VoceChatModule(_MessageChannelModuleBase[VoceChat]):
|
||
"""
|
||
解析 VoceChat 入站消息并发送渠道通知。
|
||
"""
|
||
|
||
# 管理员配置键,与渠道管理员解析器保持一致
|
||
_admin_config_key = "VOCECHAT_ADMINS"
|
||
_notification_user_target_keys = ("vocechat_userid",)
|
||
_notification_admin_default_config_keys = ("VOCECHAT_CHANNEL_ID",)
|
||
_IMAGE_SUFFIXES = (
|
||
".png",
|
||
".jpg",
|
||
".jpeg",
|
||
".gif",
|
||
".webp",
|
||
".bmp",
|
||
".tiff",
|
||
".svg",
|
||
)
|
||
_AUDIO_SUFFIXES = (
|
||
".mp3",
|
||
".m4a",
|
||
".wav",
|
||
".ogg",
|
||
".oga",
|
||
".opus",
|
||
".aac",
|
||
".amr",
|
||
".flac",
|
||
".mpga",
|
||
".mpeg",
|
||
".webm",
|
||
)
|
||
|
||
def init_module(self) -> None:
|
||
"""
|
||
初始化模块。
|
||
"""
|
||
super().init_service(service_name=VoceChat.__name__.lower(),
|
||
service_type=VoceChat)
|
||
self._channel = NotificationChannel.VoceChat
|
||
|
||
@staticmethod
|
||
def get_name() -> str:
|
||
"""
|
||
获取模块名称。
|
||
"""
|
||
return "VoceChat"
|
||
|
||
@staticmethod
|
||
def get_type() -> ModuleType:
|
||
"""
|
||
获取模块类型。
|
||
"""
|
||
return ModuleType.Notification
|
||
|
||
@staticmethod
|
||
def get_subtype() -> NotificationChannel:
|
||
"""
|
||
获取模块子类型。
|
||
"""
|
||
return NotificationChannel.VoceChat
|
||
|
||
@staticmethod
|
||
def get_priority() -> int:
|
||
"""
|
||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效。
|
||
"""
|
||
return 4
|
||
|
||
def stop(self) -> None:
|
||
"""
|
||
VoceChat 客户端无需额外停止操作。
|
||
"""
|
||
pass
|
||
|
||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||
"""
|
||
VoceChat 不提供额外的模块初始化设置。
|
||
"""
|
||
return None
|
||
|
||
@staticmethod
|
||
def _send_admin_denied(
|
||
client: Optional[VoceChat], userid: Optional[Union[str, int]]
|
||
) -> None:
|
||
"""
|
||
向 VoceChat 非管理员用户发送命令拒绝提示。
|
||
"""
|
||
if client and userid:
|
||
client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid))
|
||
|
||
def message_parser(self, source: str, body: Any, form: Any,
|
||
args: Any) -> Optional[IncomingMessage]:
|
||
"""
|
||
将 VoceChat 入站消息转换为统一消息,并校验管理员命令权限。
|
||
|
||
接收人标识用于回复路由,管理员身份按实际发送人匹配。
|
||
|
||
:param source: 通知服务配置名称
|
||
:param body: 原始 JSON 请求体;Synology Chat 使用 form 字段
|
||
:param form: 群晖回调表单;VoceChat 不使用此参数
|
||
:param args: 兼容统一入口的查询参数,当前不使用
|
||
:return: 有效文本或附件消息;配置缺失、格式无效、被拒绝或解析失败时返回 None
|
||
"""
|
||
try:
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
msg_body = json.loads(body)
|
||
msg_type = msg_body.get("detail", {}).get("type")
|
||
if msg_type not in ("normal", "reply"):
|
||
return None
|
||
logger.debug(f"收到VoceChat请求:{msg_body}")
|
||
detail = msg_body.get("detail", {}) or {}
|
||
content_type = detail.get("content_type") or ""
|
||
content = detail.get("content")
|
||
images = self._extract_images(detail)
|
||
audio_refs = self._extract_audio_refs(detail)
|
||
files = self._extract_files(detail)
|
||
text = None
|
||
if content_type in ("text/plain", "text/markdown") and isinstance(content, str):
|
||
text = content
|
||
gid = msg_body.get("target", {}).get("gid")
|
||
from_uid = msg_body.get("from_uid")
|
||
if from_uid is None:
|
||
return None
|
||
actor_userid = f"UID#{from_uid}"
|
||
channel_id = (client_config.config or {}).get("VOCECHAT_CHANNEL_ID")
|
||
if gid and str(gid) == str(channel_id):
|
||
userid = f"GID#{gid}"
|
||
else:
|
||
userid = f"UID#{msg_body.get('from_uid')}"
|
||
|
||
if (text or images or audio_refs or files) and userid:
|
||
if text and text.startswith("/") and self._should_reject_admin_command(
|
||
client_config.config, from_uid, actor_userid
|
||
):
|
||
self._send_admin_denied(client, userid)
|
||
return None
|
||
logger.info(
|
||
f"收到来自 {client_config.name} 的VoceChat消息:"
|
||
f"userid={userid}, text={text}, images={len(images) if images else 0}, "
|
||
f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}"
|
||
)
|
||
return IncomingMessage(channel=NotificationChannel.VoceChat, source=client_config.name,
|
||
userid=userid, username=userid,
|
||
is_channel_admin=matches_channel_admin(
|
||
NotificationChannel.VoceChat, client_config.config,
|
||
from_uid, actor_userid,
|
||
), text=text or "",
|
||
images=images, audio_refs=audio_refs, files=files)
|
||
except Exception as err:
|
||
logger.error(f"VoceChat消息处理发生错误:{str(err)}")
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_images(
|
||
cls, detail: dict[str, Any]
|
||
) -> Optional[List[IncomingMessage.MessageImage]]:
|
||
"""
|
||
从 VoceChat 文件消息中提取图片引用。
|
||
"""
|
||
content_type = detail.get("content_type") or ""
|
||
if content_type != "vocechat/file":
|
||
return None
|
||
properties = detail.get("properties") or {}
|
||
mime_type = (
|
||
properties.get("content_type")
|
||
or properties.get("mime_type")
|
||
or properties.get("contentType")
|
||
or ""
|
||
).lower()
|
||
file_path = (
|
||
properties.get("path")
|
||
or properties.get("file_path")
|
||
or properties.get("storage_path")
|
||
or detail.get("content")
|
||
)
|
||
direct_url = (
|
||
properties.get("url")
|
||
or properties.get("download_url")
|
||
or properties.get("file_url")
|
||
)
|
||
file_name = (
|
||
properties.get("name")
|
||
or properties.get("filename")
|
||
or (str(file_path).rsplit("/", 1)[-1] if file_path else "")
|
||
).lower()
|
||
|
||
is_image = mime_type.startswith("image/") or file_name.endswith(cls._IMAGE_SUFFIXES)
|
||
if not is_image:
|
||
return None
|
||
if isinstance(direct_url, str) and direct_url.startswith("http"):
|
||
return [
|
||
IncomingMessage.MessageImage(
|
||
ref=direct_url,
|
||
name=properties.get("name") or properties.get("filename"),
|
||
mime_type=mime_type or None,
|
||
size=properties.get("size"),
|
||
)
|
||
]
|
||
if isinstance(file_path, str) and file_path:
|
||
return [
|
||
IncomingMessage.MessageImage(
|
||
ref=f"vocechat://file/{quote(file_path, safe='')}",
|
||
name=properties.get("name") or properties.get("filename"),
|
||
mime_type=mime_type or None,
|
||
size=properties.get("size"),
|
||
)
|
||
]
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_audio_refs(cls, detail: dict[str, Any]) -> Optional[List[str]]:
|
||
"""
|
||
从 VoceChat 文件消息中提取音频引用。
|
||
"""
|
||
content_type = detail.get("content_type") or ""
|
||
if content_type != "vocechat/file":
|
||
return None
|
||
properties = detail.get("properties") or {}
|
||
mime_type = (
|
||
properties.get("content_type")
|
||
or properties.get("mime_type")
|
||
or properties.get("contentType")
|
||
or ""
|
||
).lower()
|
||
file_path = (
|
||
properties.get("path")
|
||
or properties.get("file_path")
|
||
or properties.get("storage_path")
|
||
or detail.get("content")
|
||
)
|
||
file_name = (
|
||
properties.get("name")
|
||
or properties.get("filename")
|
||
or (str(file_path).rsplit("/", 1)[-1] if file_path else "")
|
||
).lower()
|
||
|
||
is_audio = mime_type.startswith("audio/") or file_name.endswith(cls._AUDIO_SUFFIXES)
|
||
if not is_audio:
|
||
return None
|
||
if isinstance(file_path, str) and file_path:
|
||
return [f"vocechat://file/{quote(file_path, safe='')}"]
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_files(
|
||
cls, detail: dict[str, Any]
|
||
) -> Optional[List[IncomingMessage.MessageAttachment]]:
|
||
"""
|
||
从 VoceChat 文件消息中提取普通附件。
|
||
"""
|
||
content_type = detail.get("content_type") or ""
|
||
if content_type != "vocechat/file":
|
||
return None
|
||
properties = detail.get("properties") or {}
|
||
mime_type = (
|
||
properties.get("content_type")
|
||
or properties.get("mime_type")
|
||
or properties.get("contentType")
|
||
or ""
|
||
).lower()
|
||
file_path = (
|
||
properties.get("path")
|
||
or properties.get("file_path")
|
||
or properties.get("storage_path")
|
||
or detail.get("content")
|
||
)
|
||
file_name = (
|
||
properties.get("name")
|
||
or properties.get("filename")
|
||
or (str(file_path).rsplit("/", 1)[-1] if file_path else "")
|
||
)
|
||
lowered_name = str(file_name).lower()
|
||
is_image = mime_type.startswith("image/") or lowered_name.endswith(
|
||
cls._IMAGE_SUFFIXES
|
||
)
|
||
is_audio = mime_type.startswith("audio/") or lowered_name.endswith(
|
||
cls._AUDIO_SUFFIXES
|
||
)
|
||
if is_image or is_audio or not isinstance(file_path, str) or not file_path:
|
||
return None
|
||
return [
|
||
IncomingMessage.MessageAttachment(
|
||
ref=f"vocechat://file/{quote(file_path, safe='')}",
|
||
name=file_name,
|
||
mime_type=properties.get("content_type")
|
||
or properties.get("mime_type")
|
||
or properties.get("contentType"),
|
||
size=properties.get("size"),
|
||
)
|
||
]
|
||
|
||
def post_message(self, message: Message, **kwargs: Any) -> None:
|
||
"""
|
||
向通过渠道范围筛选的 VoceChat 配置发送普通通知。
|
||
|
||
不汇总客户端发送结果;客户端负责记录发送失败。
|
||
|
||
:param message: 提供渠道范围、接收人和标题等信息的通知消息
|
||
:param kwargs: 兼容统一通知入口的扩展参数,当前不使用
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid = self.get_notification_userid(message, "vocechat_userid")
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
texts: Sequence[Optional[str]] = message.channel_text_chunks(NotificationChannel.VoceChat) or [message.text]
|
||
for text in texts:
|
||
client.send_msg(title=message.title, text=text,
|
||
image=message.image, userid=userid, link=message.link)
|
||
|
||
def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None:
|
||
"""
|
||
向通过渠道范围筛选的 VoceChat 配置发送媒体候选列表。
|
||
|
||
不汇总客户端发送结果;客户端负责记录发送失败。
|
||
|
||
:param message: 提供渠道范围、接收人和标题等信息的通知消息
|
||
:param medias: 待展示的媒体列表
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
userid = self.get_notification_userid(message, "vocechat_userid")
|
||
client.send_msg(title=message.title, userid=userid)
|
||
client.send_medias_msg(title=message.title, medias=medias,
|
||
userid=userid, link=message.link)
|
||
|
||
def post_torrents_message(self, message: Message, torrents: List[Context]) -> None:
|
||
"""
|
||
向通过渠道范围筛选的 VoceChat 配置发送种子候选列表。
|
||
|
||
不汇总客户端发送结果;客户端负责记录发送失败。
|
||
|
||
:param message: 提供渠道范围、接收人和标题等信息的通知消息
|
||
:param torrents: 待展示的种子上下文列表
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
userid = self.get_notification_userid(message, "vocechat_userid")
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_torrents_msg(title=message.title, torrents=torrents,
|
||
userid=userid, link=message.link)
|
||
|
||
def register_commands(self, commands: Dict[str, dict[str, Any]]) -> None:
|
||
"""
|
||
VoceChat 不支持注册命令菜单。
|
||
"""
|
||
return None
|
||
|
||
def download_vocechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
|
||
"""
|
||
将 VoceChat 图片引用下载并编码为 data URL。
|
||
|
||
:param image_ref: vocechat://file/ 开头的内部文件引用
|
||
:param source: 所属通知服务配置名称
|
||
:return: 包含 MIME 类型和 Base64 内容的地址;无法获取文件时返回 None
|
||
"""
|
||
if not image_ref or not image_ref.startswith("vocechat://file/"):
|
||
return None
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
file_path = unquote(image_ref.replace("vocechat://file/", "", 1))
|
||
return client.download_file_to_data_url(file_path)
|
||
|
||
def download_vocechat_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
|
||
"""
|
||
下载 VoceChat 文件引用并返回原始字节。
|
||
|
||
:param file_ref: vocechat://file/ 开头的内部文件引用
|
||
:param source: 所属通知服务配置名称
|
||
:return: 非空文件内容;引用、配置或响应不可用时返回 None
|
||
"""
|
||
if not file_ref or not file_ref.startswith("vocechat://file/"):
|
||
return None
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
file_path = unquote(file_ref.replace("vocechat://file/", "", 1))
|
||
file_data = client.download_file(file_path)
|
||
if file_data:
|
||
content, _ = file_data
|
||
return content
|
||
return None
|