mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 11:54:50 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
475 lines
18 KiB
Python
475 lines
18 KiB
Python
"""
|
||
QQ 通知渠道路由、入站解析与附件下载入口。
|
||
|
||
主动发送对象与配额由 QQ 平台控制;后台网关接收入站消息。
|
||
"""
|
||
|
||
import json
|
||
from typing import Any, List, Optional, Tuple, Union
|
||
from urllib.parse import quote, unquote
|
||
|
||
from app.adapters.network.http import RequestUtils
|
||
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.qqbot.qqbot import QQBot
|
||
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.QQ,
|
||
lambda config: resolve_config_principal_ids(
|
||
config, "QQBOT_ADMINS", "QQ_OPENID"
|
||
),
|
||
)
|
||
|
||
|
||
class QQBotModule(_MessageChannelModuleBase[QQBot]):
|
||
"""
|
||
QQ Bot 通知模块。
|
||
"""
|
||
|
||
# 管理员配置键,与渠道管理员解析器保持一致
|
||
_admin_config_key = "QQBOT_ADMINS"
|
||
_notification_user_target_keys = (
|
||
"qq_userid",
|
||
"qq_openid",
|
||
"qq_group_openid",
|
||
"qq_group",
|
||
)
|
||
_notification_admin_default_config_keys = ("QQ_OPENID", "QQ_GROUP_OPENID")
|
||
|
||
_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:
|
||
"""
|
||
初始化 QQ Bot 通知服务并设置渠道标识。
|
||
"""
|
||
super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot)
|
||
self._channel = NotificationChannel.QQ
|
||
|
||
@staticmethod
|
||
def get_name() -> str:
|
||
"""
|
||
返回渠道的显示名称。
|
||
"""
|
||
return "QQ"
|
||
|
||
@staticmethod
|
||
def get_type() -> ModuleType:
|
||
"""
|
||
声明模块属于通知服务。
|
||
"""
|
||
return ModuleType.Notification
|
||
|
||
@staticmethod
|
||
def get_subtype() -> NotificationChannel:
|
||
"""
|
||
返回 QQ 通知渠道标识。
|
||
"""
|
||
return NotificationChannel.QQ
|
||
|
||
@staticmethod
|
||
def get_priority() -> int:
|
||
"""
|
||
返回通知模块的加载优先级。
|
||
"""
|
||
return 10
|
||
|
||
def _commands_enabled(self, config: Optional[dict[str, Any]]) -> bool:
|
||
"""
|
||
QQ 机器人客户端未提供命令注册/删除 API,跳过命令注册,。
|
||
避免基类默认钩子调用不存在的 client.register_commands。
|
||
"""
|
||
return False
|
||
|
||
def stop(self) -> bool:
|
||
"""
|
||
停止全部 QQ Bot 实例,并返回资源是否全部收敛。
|
||
"""
|
||
return self._stop_service_instances()
|
||
|
||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||
"""
|
||
保留设置初始化钩子,实例配置由通知服务管理。
|
||
"""
|
||
return None
|
||
|
||
@staticmethod
|
||
def _send_admin_denied(
|
||
client: Optional[QQBot], userid: Optional[Union[str, int]]
|
||
) -> None:
|
||
"""
|
||
向 QQ 非管理员用户发送命令拒绝提示。
|
||
"""
|
||
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]:
|
||
"""
|
||
解析网关单聊或群聊消息,校验发言人的管理员权限。
|
||
|
||
群聊回复路由使用 group:群标识,管理员身份仍按实际发言人判断。
|
||
|
||
:param source: 通知配置名称
|
||
:param body: 网关事件字典或编码为 JSON 的字节
|
||
:param form: 兼容统一入口的表单参数,当前不使用
|
||
:param args: 兼容统一入口的查询参数,当前不使用
|
||
:return: 统一消息对象;配置缺失、内容为空、不支持的事件或权限不足时返回 None
|
||
"""
|
||
client_config = self.get_config(source)
|
||
if not client_config:
|
||
return None
|
||
client = self.get_instance(client_config.name)
|
||
try:
|
||
if isinstance(body, bytes):
|
||
msg_body = json.loads(body)
|
||
elif isinstance(body, dict):
|
||
msg_body = body
|
||
else:
|
||
return None
|
||
except (json.JSONDecodeError, TypeError) as err:
|
||
logger.debug(f"解析 QQ 消息失败: {err}")
|
||
return None
|
||
|
||
msg_type = msg_body.get("type")
|
||
content = (msg_body.get("content") or "").strip()
|
||
images = self._extract_images(msg_body)
|
||
audio_refs = self._extract_audio_refs(msg_body)
|
||
files = self._extract_files(msg_body)
|
||
if not content and not images and not audio_refs and not files:
|
||
return None
|
||
|
||
if msg_type == "C2C_MESSAGE_CREATE":
|
||
author = msg_body.get("author", {})
|
||
user_openid = author.get("user_openid", "")
|
||
if not user_openid:
|
||
return None
|
||
if content.startswith("/") and self._should_reject_admin_command(
|
||
client_config.config, user_openid
|
||
):
|
||
self._send_admin_denied(client, user_openid)
|
||
return None
|
||
logger.info(
|
||
f"收到 QQ 私聊消息: userid={user_openid}, "
|
||
f"text={(content or '')[:50]}..., 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.QQ,
|
||
source=client_config.name,
|
||
userid=user_openid,
|
||
username=user_openid,
|
||
is_channel_admin=matches_channel_admin(
|
||
NotificationChannel.QQ,
|
||
client_config.config,
|
||
user_openid,
|
||
),
|
||
text=content,
|
||
images=images,
|
||
audio_refs=audio_refs,
|
||
files=files,
|
||
)
|
||
elif msg_type == "GROUP_AT_MESSAGE_CREATE":
|
||
author = msg_body.get("author", {})
|
||
member_openid = author.get("member_openid", "")
|
||
group_openid = msg_body.get("group_openid", "")
|
||
# 群聊用 group:group_openid 作为 userid,便于回复时识别
|
||
userid = f"group:{group_openid}" if group_openid else member_openid
|
||
if content.startswith("/") and self._should_reject_admin_command(
|
||
client_config.config, member_openid
|
||
):
|
||
self._send_admin_denied(client, userid)
|
||
return None
|
||
logger.info(
|
||
f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, "
|
||
f"text={(content or '')[:50]}..., 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.QQ,
|
||
source=client_config.name,
|
||
userid=userid,
|
||
username=member_openid or group_openid,
|
||
is_channel_admin=matches_channel_admin(
|
||
NotificationChannel.QQ,
|
||
client_config.config,
|
||
member_openid,
|
||
),
|
||
text=content,
|
||
images=images,
|
||
audio_refs=audio_refs,
|
||
files=files,
|
||
)
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_images(
|
||
cls, msg_body: dict[str, Any]
|
||
) -> Optional[List[IncomingMessage.MessageImage]]:
|
||
"""
|
||
提取 QQ 图片附件与兼容图片字段,按引用去重。
|
||
"""
|
||
images: List[IncomingMessage.MessageImage] = []
|
||
attachments = msg_body.get("attachments") or []
|
||
if isinstance(attachments, list):
|
||
for attachment in attachments:
|
||
if not isinstance(attachment, dict):
|
||
continue
|
||
url = attachment.get("url") or attachment.get("proxy_url")
|
||
if not url:
|
||
continue
|
||
content_type = (
|
||
attachment.get("content_type")
|
||
or attachment.get("mime_type")
|
||
or ""
|
||
).lower()
|
||
filename = (
|
||
attachment.get("filename")
|
||
or attachment.get("name")
|
||
or ""
|
||
).lower()
|
||
if content_type.startswith("image/") or filename.endswith(cls._IMAGE_SUFFIXES):
|
||
images.append(
|
||
IncomingMessage.MessageImage(
|
||
ref=url,
|
||
name=attachment.get("filename") or attachment.get("name"),
|
||
mime_type=attachment.get("content_type")
|
||
or attachment.get("mime_type"),
|
||
size=attachment.get("size"),
|
||
)
|
||
)
|
||
|
||
for key in ("image", "image_url", "pic_url"):
|
||
value = msg_body.get(key)
|
||
if isinstance(value, str) and value.startswith("http"):
|
||
images.append(IncomingMessage.MessageImage(ref=value))
|
||
|
||
extra_images = msg_body.get("images")
|
||
if isinstance(extra_images, list):
|
||
for item in extra_images:
|
||
if isinstance(item, str) and item.startswith("http"):
|
||
images.append(IncomingMessage.MessageImage(ref=item))
|
||
elif isinstance(item, dict):
|
||
url = item.get("url") or item.get("image_url")
|
||
if isinstance(url, str) and url.startswith("http"):
|
||
images.append(
|
||
IncomingMessage.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: List[IncomingMessage.MessageImage] = []
|
||
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, msg_body: dict[str, Any]) -> Optional[List[str]]:
|
||
"""
|
||
提取音频附件,并生成可回读的 QQ 文件引用。
|
||
"""
|
||
audio_refs: List[str] = []
|
||
attachments = msg_body.get("attachments") or []
|
||
if isinstance(attachments, list):
|
||
for attachment in attachments:
|
||
if not isinstance(attachment, dict):
|
||
continue
|
||
url = attachment.get("url") or attachment.get("proxy_url")
|
||
if not url:
|
||
continue
|
||
content_type = (
|
||
attachment.get("content_type")
|
||
or attachment.get("mime_type")
|
||
or ""
|
||
).lower()
|
||
filename = (
|
||
attachment.get("filename")
|
||
or attachment.get("name")
|
||
or ""
|
||
).lower()
|
||
if content_type.startswith("audio/") or filename.endswith(cls._AUDIO_SUFFIXES):
|
||
audio_refs.append(f"qq://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 _extract_files(
|
||
cls, msg_body: dict[str, Any]
|
||
) -> Optional[List[IncomingMessage.MessageAttachment]]:
|
||
"""
|
||
提取图片和音频以外的文件附件,保留文件元数据。
|
||
"""
|
||
files: List[IncomingMessage.MessageAttachment] = []
|
||
attachments = msg_body.get("attachments") or []
|
||
if isinstance(attachments, list):
|
||
for attachment in attachments:
|
||
if not isinstance(attachment, dict):
|
||
continue
|
||
url = attachment.get("url") or attachment.get("proxy_url")
|
||
if not url:
|
||
continue
|
||
content_type = (
|
||
attachment.get("content_type")
|
||
or attachment.get("mime_type")
|
||
or ""
|
||
).lower()
|
||
filename = (
|
||
attachment.get("filename") or attachment.get("name") or ""
|
||
).lower()
|
||
is_image = content_type.startswith("image/") or filename.endswith(
|
||
cls._IMAGE_SUFFIXES
|
||
)
|
||
is_audio = content_type.startswith("audio/") or filename.endswith(
|
||
cls._AUDIO_SUFFIXES
|
||
)
|
||
if is_image or is_audio:
|
||
continue
|
||
files.append(
|
||
IncomingMessage.MessageAttachment(
|
||
ref=f"qq://file/{quote(url, safe='')}",
|
||
name=attachment.get("filename") or attachment.get("name"),
|
||
mime_type=attachment.get("content_type")
|
||
or attachment.get("mime_type"),
|
||
size=attachment.get("size"),
|
||
)
|
||
)
|
||
return files or None
|
||
|
||
def download_qq_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
|
||
"""
|
||
下载 QQ 文件引用中的音频或普通附件。
|
||
|
||
:param file_ref: qq://file/ 开头的内部文件引用
|
||
:param source: 所属通知配置名称
|
||
:return: 非空文件字节;引用、配置或下载不可用时返回 None
|
||
"""
|
||
if not file_ref or not file_ref.startswith("qq://file/"):
|
||
return None
|
||
if not self.get_config(source):
|
||
return None
|
||
file_url = unquote(file_ref.replace("qq://file/", "", 1))
|
||
resp = RequestUtils(timeout=30).get_res(file_url)
|
||
if resp and isinstance(resp.content, bytes) and resp.content:
|
||
return resp.content
|
||
return None
|
||
|
||
def post_message(self, message: Message, **kwargs: Any) -> None:
|
||
"""
|
||
按通知路由发送 QQ 消息,并保留原正文作为权限不足时的回退。
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
targets = self.get_notification_targets(message)
|
||
userid = message.userid
|
||
if not userid and targets:
|
||
userid = targets.get("qq_userid") or targets.get("qq_openid")
|
||
if not userid:
|
||
userid = targets.get("qq_group_openid") or targets.get("qq_group")
|
||
if userid:
|
||
userid = f"group:{userid}"
|
||
# 无 userid 且无默认配置时,由 client 向曾发过消息的用户/群广播
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_msg(
|
||
title=message.title or "",
|
||
text=message.text,
|
||
image=message.image,
|
||
link=message.link,
|
||
userid=str(userid) if userid is not None else None,
|
||
targets=targets,
|
||
formatted_texts=message.channel_text_chunks(NotificationChannel.QQ),
|
||
)
|
||
|
||
def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None:
|
||
"""
|
||
按实例和用户路由发送 QQ 媒体候选列表。
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
targets = self.get_notification_targets(message)
|
||
userid = message.userid
|
||
if not userid and targets:
|
||
userid = targets.get("qq_userid") or targets.get("qq_openid")
|
||
if not userid:
|
||
g = targets.get("qq_group_openid") or targets.get("qq_group")
|
||
if g:
|
||
userid = f"group:{g}"
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_medias_msg(
|
||
medias=medias,
|
||
userid=str(userid) if userid is not None else None,
|
||
title=message.title,
|
||
link=message.link,
|
||
targets=targets,
|
||
)
|
||
|
||
def post_torrents_message(
|
||
self, message: Message, torrents: List[Context]
|
||
) -> None:
|
||
"""
|
||
按实例和用户路由发送 QQ 种子候选列表。
|
||
"""
|
||
for conf in self.get_configs().values():
|
||
if not self.check_message(message, conf.name):
|
||
continue
|
||
targets = self.get_notification_targets(message)
|
||
userid = message.userid
|
||
if not userid and targets:
|
||
userid = targets.get("qq_userid") or targets.get("qq_openid")
|
||
if not userid:
|
||
g = targets.get("qq_group_openid") or targets.get("qq_group")
|
||
if g:
|
||
userid = f"group:{g}"
|
||
client = self.get_instance(conf.name)
|
||
if client:
|
||
client.send_torrents_msg(
|
||
torrents=torrents,
|
||
userid=str(userid) if userid is not None else None,
|
||
title=message.title,
|
||
link=message.link,
|
||
targets=targets,
|
||
)
|