Files
MoviePilot/app/modules/qqbot/module.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.
"""
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,
)