mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 14:52:45 +08:00
386 lines
14 KiB
Python
386 lines
14 KiB
Python
import json
|
||
from urllib.parse import quote, unquote
|
||
from typing import Optional, Union, List, Tuple, Any, Dict
|
||
|
||
from app.core.context import Context, MediaInfo
|
||
from app.log import logger
|
||
from app.modules import _ModuleBase, _MessageBase
|
||
from app.modules.vocechat.vocechat import VoceChat
|
||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||
from app.schemas.types import ModuleType
|
||
|
||
|
||
class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
|
||
_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 = MessageChannel.VoceChat
|
||
|
||
@staticmethod
|
||
def get_name() -> str:
|
||
return "VoceChat"
|
||
|
||
@staticmethod
|
||
def get_type() -> ModuleType:
|
||
"""
|
||
获取模块类型
|
||
"""
|
||
return ModuleType.Notification
|
||
|
||
@staticmethod
|
||
def get_subtype() -> MessageChannel:
|
||
"""
|
||
获取模块子类型
|
||
"""
|
||
return MessageChannel.VoceChat
|
||
|
||
@staticmethod
|
||
def get_priority() -> int:
|
||
"""
|
||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||
"""
|
||
return 4
|
||
|
||
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"VoceChat {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:
|
||
"""
|
||
{
|
||
"created_at": 1672048481664, //消息创建的时间戳
|
||
"detail": {
|
||
"content": "hello this is my message to you", //消息内容
|
||
"content_type": "text/plain", //消息类型,text/plain:纯文本消息,text/markdown:markdown消息,vocechat/file:文件类消息
|
||
"expires_in": null, //消息过期时长,如果有大于0数字,说明该消息是个限时消息
|
||
"properties": null, //一些有关消息的元数据,比如at信息,文件消息的具体类型信息,如果是个图片消息,还会有一些宽高,图片名称等元信息
|
||
"type": "normal" //消息类型,normal代表是新消息
|
||
},
|
||
"from_uid": 7910, //来自于谁
|
||
"mid": 2978, //消息ID
|
||
"target": { "gid": 2 } //发送给谁,gid代表是发送给频道,uid代表是发送给个人,此时的数据结构举例:{"uid":1}
|
||
}
|
||
"""
|
||
# 获取服务配置
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
# 报文体
|
||
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
|
||
# 用户ID
|
||
gid = msg_body.get("target", {}).get("gid")
|
||
channel_id = client_config.config.get("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:
|
||
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 CommingMessage(channel=MessageChannel.VoceChat, source=client_config.name,
|
||
userid=userid, username=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
|
||
) -> Optional[List[CommingMessage.MessageImage]]:
|
||
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 [
|
||
CommingMessage.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 [
|
||
CommingMessage.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) -> Optional[List[str]]:
|
||
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
|
||
) -> Optional[List[CommingMessage.MessageAttachment]]:
|
||
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 [
|
||
CommingMessage.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: 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 message.userid and targets:
|
||
userid = targets.get('vocechat_userid')
|
||
client: VoceChat = 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: VoceChat = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_msg(title=message.title, userid=message.userid)
|
||
client.send_medias_msg(title=message.title, medias=medias,
|
||
userid=message.userid, link=message.link)
|
||
|
||
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
|
||
targets = message.targets
|
||
userid = message.userid
|
||
if not userid and targets is not None:
|
||
userid = targets.get('vocechat_userid')
|
||
if not userid:
|
||
logger.warn(f"用户没有指定 VoceChat用户ID,消息无法发送")
|
||
return
|
||
client: VoceChat = 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]):
|
||
pass
|
||
|
||
def download_vocechat_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
|
||
"""
|
||
下载 VoceChat 图片并转换为 data URL
|
||
"""
|
||
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: VoceChat = 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 文件并返回原始字节
|
||
"""
|
||
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: VoceChat = 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
|