mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 23:02:09 +08:00
359 lines
13 KiB
Python
359 lines
13 KiB
Python
import json
|
||
from typing import Optional, Union, List, Tuple, Any
|
||
from urllib.parse import quote, unquote
|
||
|
||
from app.core.context import MediaInfo, Context
|
||
from app.log import logger
|
||
from app.modules import _ModuleBase, _MessageBase
|
||
from app.modules.synologychat.synologychat import SynologyChat
|
||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||
from app.schemas.types import ModuleType
|
||
from app.utils.http import RequestUtils
|
||
|
||
|
||
class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
||
_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=SynologyChat.__name__.lower(),
|
||
service_type=SynologyChat)
|
||
self._channel = MessageChannel.SynologyChat
|
||
|
||
@staticmethod
|
||
def get_name() -> str:
|
||
return "Synology Chat"
|
||
|
||
@staticmethod
|
||
def get_type() -> ModuleType:
|
||
"""
|
||
获取模块类型
|
||
"""
|
||
return ModuleType.Notification
|
||
|
||
@staticmethod
|
||
def get_subtype() -> MessageChannel:
|
||
"""
|
||
获取模块子类型
|
||
"""
|
||
return MessageChannel.SynologyChat
|
||
|
||
@staticmethod
|
||
def get_priority() -> int:
|
||
"""
|
||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||
"""
|
||
return 5
|
||
|
||
def stop(self):
|
||
pass
|
||
|
||
def test(self) -> Optional[Tuple[bool, str]]:
|
||
"""
|
||
测试模块连接性
|
||
"""
|
||
if not self.get_instances():
|
||
return None
|
||
for name, client in self.get_instances().items():
|
||
state = client.get_state()
|
||
if not state:
|
||
return False, f"Synology Chat {name} 未就绪"
|
||
return True, ""
|
||
|
||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||
pass
|
||
|
||
def message_parser(self, source: str, body: Any, form: Any,
|
||
args: Any) -> Optional[CommingMessage]:
|
||
"""
|
||
解析消息内容,返回字典,注意以下约定值:
|
||
userid: 用户ID
|
||
username: 用户名
|
||
text: 内容
|
||
:param source: 消息来源
|
||
:param body: 请求体
|
||
:param form: 表单
|
||
:param args: 参数
|
||
:return: 渠道、消息体
|
||
"""
|
||
try:
|
||
# 获取服务配置
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client: SynologyChat = self.get_instance(client_config.name)
|
||
if not client:
|
||
return None
|
||
# 解析消息
|
||
message: dict = form
|
||
if not message:
|
||
return None
|
||
# 校验token
|
||
token = message.get("token")
|
||
if not token or not client.check_token(token):
|
||
return None
|
||
# 文本
|
||
text = message.get("text")
|
||
# 用户ID
|
||
user_id = int(message.get("user_id"))
|
||
# 获取用户名
|
||
user_name = message.get("username")
|
||
images = self._extract_images(message)
|
||
audio_refs = self._extract_audio_refs(message)
|
||
files = self._extract_files(message)
|
||
if (text or images or audio_refs or files) and user_id:
|
||
logger.info(
|
||
f"收到来自 {client_config.name} 的SynologyChat消息:"
|
||
f"userid={user_id}, username={user_name}, text={text}, "
|
||
f"images={len(images) if images else 0}, audios={len(audio_refs) if audio_refs else 0}, "
|
||
f"files={len(files) if files else 0}"
|
||
)
|
||
return CommingMessage(channel=MessageChannel.SynologyChat, source=client_config.name,
|
||
userid=user_id, username=user_name, text=text or "",
|
||
images=images, audio_refs=audio_refs, files=files)
|
||
except Exception as err:
|
||
logger.debug(f"解析SynologyChat消息失败:{str(err)}")
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_images(
|
||
cls, message: dict
|
||
) -> Optional[List[CommingMessage.MessageImage]]:
|
||
images = []
|
||
for key in ("file_url", "image_url", "pic_url"):
|
||
value = message.get(key)
|
||
if isinstance(value, str) and cls._looks_like_image(value):
|
||
images.append(CommingMessage.MessageImage(ref=value))
|
||
|
||
for key in ("attachments", "files"):
|
||
raw_value = message.get(key)
|
||
if not raw_value:
|
||
continue
|
||
try:
|
||
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||
except Exception:
|
||
parsed = raw_value
|
||
items = parsed if isinstance(parsed, list) else [parsed]
|
||
for item in items:
|
||
if isinstance(item, str) and cls._looks_like_image(item):
|
||
images.append(CommingMessage.MessageImage(ref=item))
|
||
elif isinstance(item, dict):
|
||
url = item.get("url") or item.get("file_url") or item.get("image_url")
|
||
if isinstance(url, str) and cls._looks_like_image(url):
|
||
images.append(
|
||
CommingMessage.MessageImage(
|
||
ref=url,
|
||
name=item.get("name") or item.get("filename"),
|
||
mime_type=item.get("content_type")
|
||
or item.get("mime_type"),
|
||
size=item.get("size"),
|
||
)
|
||
)
|
||
|
||
deduped = []
|
||
for image in images:
|
||
if image.ref not in [item.ref for item in deduped]:
|
||
deduped.append(image)
|
||
return deduped or None
|
||
|
||
@classmethod
|
||
def _extract_audio_refs(cls, message: dict) -> Optional[List[str]]:
|
||
audio_refs = []
|
||
for key in ("audio_url", "voice_url", "file_url"):
|
||
value = message.get(key)
|
||
if isinstance(value, str) and cls._looks_like_audio(value):
|
||
audio_refs.append(f"synology://file/{quote(value, safe='')}")
|
||
|
||
for key in ("attachments", "files"):
|
||
raw_value = message.get(key)
|
||
if not raw_value:
|
||
continue
|
||
try:
|
||
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||
except Exception:
|
||
parsed = raw_value
|
||
items = parsed if isinstance(parsed, list) else [parsed]
|
||
for item in items:
|
||
if isinstance(item, str) and cls._looks_like_audio(item):
|
||
audio_refs.append(f"synology://file/{quote(item, safe='')}")
|
||
elif isinstance(item, dict):
|
||
url = item.get("url") or item.get("file_url") or item.get("audio_url")
|
||
if not isinstance(url, str):
|
||
continue
|
||
content_type = (
|
||
item.get("content_type")
|
||
or item.get("mime_type")
|
||
or ""
|
||
).lower()
|
||
name = (
|
||
item.get("name")
|
||
or item.get("filename")
|
||
or ""
|
||
).lower()
|
||
if content_type.startswith("audio/") or cls._looks_like_audio(url) or name.endswith(cls._AUDIO_SUFFIXES):
|
||
audio_refs.append(f"synology://file/{quote(url, safe='')}")
|
||
|
||
deduped = []
|
||
for audio_ref in audio_refs:
|
||
if audio_ref not in deduped:
|
||
deduped.append(audio_ref)
|
||
return deduped or None
|
||
|
||
@classmethod
|
||
def _looks_like_image(cls, value: str) -> bool:
|
||
if not value or not isinstance(value, str):
|
||
return False
|
||
lowered = value.lower()
|
||
return lowered.startswith("http") and any(
|
||
suffix in lowered for suffix in cls._IMAGE_SUFFIXES
|
||
)
|
||
|
||
@classmethod
|
||
def _looks_like_audio(cls, value: str) -> bool:
|
||
if not value or not isinstance(value, str):
|
||
return False
|
||
lowered = value.lower()
|
||
return lowered.startswith("http") and any(
|
||
suffix in lowered for suffix in cls._AUDIO_SUFFIXES
|
||
)
|
||
|
||
@classmethod
|
||
def _extract_files(
|
||
cls, message: dict
|
||
) -> Optional[List[CommingMessage.MessageAttachment]]:
|
||
files = []
|
||
for key in ("attachments", "files"):
|
||
raw_value = message.get(key)
|
||
if not raw_value:
|
||
continue
|
||
try:
|
||
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
|
||
except Exception:
|
||
parsed = raw_value
|
||
items = parsed if isinstance(parsed, list) else [parsed]
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
url = item.get("url") or item.get("file_url") or item.get("download_url")
|
||
if not isinstance(url, str) or not url.startswith("http"):
|
||
continue
|
||
content_type = (
|
||
item.get("content_type") or item.get("mime_type") or ""
|
||
).lower()
|
||
name = (item.get("name") or item.get("filename") or "").lower()
|
||
is_image = content_type.startswith("image/") or name.endswith(
|
||
cls._IMAGE_SUFFIXES
|
||
) or cls._looks_like_image(url)
|
||
is_audio = content_type.startswith("audio/") or name.endswith(
|
||
cls._AUDIO_SUFFIXES
|
||
) or cls._looks_like_audio(url)
|
||
if is_image or is_audio:
|
||
continue
|
||
files.append(
|
||
CommingMessage.MessageAttachment(
|
||
ref=f"synology://file/{quote(url, safe='')}",
|
||
name=item.get("name") or item.get("filename"),
|
||
mime_type=item.get("content_type") or item.get("mime_type"),
|
||
size=item.get("size"),
|
||
)
|
||
)
|
||
|
||
deduped = []
|
||
seen_refs = set()
|
||
for file_item in files:
|
||
if file_item.ref in seen_refs:
|
||
continue
|
||
seen_refs.add(file_item.ref)
|
||
deduped.append(file_item)
|
||
return deduped or None
|
||
|
||
def download_synologychat_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
|
||
"""
|
||
下载 Synology Chat 音频文件并返回原始字节
|
||
"""
|
||
if not file_ref or not file_ref.startswith("synology://file/"):
|
||
return None
|
||
if not self.get_config(source):
|
||
return None
|
||
file_url = unquote(file_ref.replace("synology://file/", "", 1))
|
||
resp = RequestUtils(timeout=30).get_res(file_url)
|
||
if resp and resp.content:
|
||
return resp.content
|
||
return None
|
||
|
||
def post_message(self, message: Notification, **kwargs) -> None:
|
||
"""
|
||
发送消息
|
||
:param message: 消息体
|
||
:return: 成功或失败
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
targets = message.targets
|
||
userid = message.userid
|
||
if not userid and targets is not None:
|
||
userid = targets.get('synologychat_userid')
|
||
if not userid:
|
||
logger.warn(f"用户没有指定 SynologyChat用户ID,消息无法发送")
|
||
return
|
||
client: SynologyChat = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_msg(title=message.title, text=message.text,
|
||
image=message.image, userid=userid, link=message.link)
|
||
|
||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||
"""
|
||
发送媒体信息选择列表
|
||
:param message: 消息体
|
||
:param medias: 媒体列表
|
||
:return: 成功或失败
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
client: SynologyChat = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_medias_msg(title=message.title, medias=medias,
|
||
userid=message.userid)
|
||
|
||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||
"""
|
||
发送种子信息选择列表
|
||
:param message: 消息体
|
||
:param torrents: 种子列表
|
||
:return: 成功或失败
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
client: SynologyChat = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_torrents_msg(title=message.title, torrents=torrents,
|
||
userid=message.userid, link=message.link)
|