Files
MoviePilot/app/modules/discord/__init__.py
Aqr-K fc0eed08cc fix: preserve directed messaging and channel interaction contracts (#7148)
* fix: retain plugin lifecycle ownership and recover installation state

* fix: preserve directed messaging and channel interaction contracts

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:49:53 +08:00

678 lines
26 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.
"""
Discord 通知路由、入站解析、附件下载与输入状态反馈。
"""
import json
from typing import Any, Dict, List, Optional, Sequence, 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.runtime.log import logger
from app.schemas.event import CommandRegisterEventData
from app.schemas.message import IncomingMessage, Message, MessageResponse
from app.schemas.types import ModuleType, NotificationChannel
# SDK 缺失或加载失败时保留其他通知渠道的可用性。
Discord: Any = None
try:
from app.modules.discord.discord import Discord as _Discord
Discord = _Discord
except Exception as err:
logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}")
register_channel_admin_resolver(
NotificationChannel.Discord,
lambda config: resolve_config_principal_ids(config, "DISCORD_ADMINS"),
)
class DiscordModule(_MessageChannelModuleBase[Discord]):
"""
处理 Discord 消息收发、附件和通知路由。
"""
# 管理员配置键,与渠道管理员解析器保持一致。
_admin_config_key = "DISCORD_ADMINS"
_notification_user_target_keys = ("discord_userid",)
_notification_admin_default_config_keys = ("DISCORD_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:
"""
初始化模块。
"""
if not Discord:
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
return
super().init_service(
service_name=Discord.__name__.lower(), service_type=Discord
)
self._channel = NotificationChannel.Discord
@staticmethod
def get_name() -> str:
"""
返回渠道的显示名称。
"""
return "Discord"
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型。
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> NotificationChannel:
"""
获取模块子类型。
"""
return NotificationChannel.Discord
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效。
"""
return 4
def stop(self) -> bool:
"""
停止全部 Discord 实例,并返回资源是否全部收敛。
"""
return self._stop_service_instances()
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""
保留设置初始化钩子,实例配置由通知服务管理。
"""
return None
@staticmethod
def _send_admin_denied(
client: Optional[Discord],
userid: Optional[Union[str, int]],
chat_id: Optional[Union[str, int]] = None,
) -> None:
"""
向 Discord 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(
title="只有管理员才有权限执行此命令",
userid=str(userid),
original_chat_id=str(chat_id) if chat_id else None,
)
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[IncomingMessage]:
"""
解析 Discord 入站消息和按钮交互,保留原频道回复信息。
管理员身份按实际发言人判断;按钮交互保留原消息和频道标识以支持编辑。
:param source: 通知服务配置名称
:param body: 客户端编码为 JSON 的消息或交互报文
:param form: 兼容统一入口的表单参数,当前不使用
:param args: 兼容统一入口的查询参数,当前不使用
:return: 统一消息对象;配置缺失、JSON 无效、内容不足或命令权限不足时返回 None
"""
client_config = self.get_config(source)
if not client_config:
return None
client: Optional[Discord] = self.get_instance(client_config.name)
try:
msg_json: dict[str, Any] = json.loads(body)
except Exception as e:
logger.debug(f"解析 Discord 消息失败:{str(e)}")
return None
if not msg_json:
return None
msg_type = msg_json.get("type")
userid = msg_json.get("userid")
username = msg_json.get("username")
if msg_type == "interaction":
callback_data = msg_json.get("callback_data")
message_id = msg_json.get("message_id")
chat_id = msg_json.get("chat_id")
if callback_data and userid:
if str(callback_data).strip().startswith("/") and self._should_reject_admin_command(
client_config.config, userid
):
self._send_admin_denied(client, userid, chat_id)
return None
logger.info(
f"收到来自 {client_config.name} 的 Discord 按钮回调:"
f"userid={userid}, username={username}, callback_data={callback_data}"
)
return IncomingMessage(
channel=NotificationChannel.Discord,
source=client_config.name,
userid=userid,
username=username,
is_channel_admin=matches_channel_admin(
NotificationChannel.Discord, client_config.config, userid
),
text=f"CALLBACK:{callback_data}",
is_callback=True,
callback_data=callback_data,
message_id=message_id,
chat_id=str(chat_id) if chat_id else None,
)
return None
if msg_type == "message":
text = msg_json.get("text")
chat_id = msg_json.get("chat_id")
images = self._extract_images(msg_json)
audio_refs = self._extract_audio_refs(msg_json)
files = self._extract_files(msg_json)
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, userid
):
self._send_admin_denied(client, userid, chat_id)
return None
logger.info(
f"收到来自 {client_config.name} 的 Discord 消息:"
f"userid={userid}, username={username}, 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 IncomingMessage(
channel=NotificationChannel.Discord,
source=client_config.name,
userid=userid,
username=username,
is_channel_admin=matches_channel_admin(
NotificationChannel.Discord, client_config.config, userid
),
text=text,
chat_id=str(chat_id) if chat_id else None,
images=images,
audio_refs=audio_refs,
files=files,
)
return None
@staticmethod
def _extract_images(
msg_json: dict[str, Any],
) -> Optional[List[IncomingMessage.MessageImage]]:
"""
从Discord消息中提取图片URL。
"""
attachments = msg_json.get("attachments", [])
if not attachments:
return None
images = []
for attachment in attachments:
url = attachment.get("url") or attachment.get("proxy_url")
if not url:
continue
content_type = (attachment.get("content_type") or "").lower()
filename = (attachment.get("filename") or "").lower()
if (
attachment.get("type") == "image"
or content_type.startswith("image/")
or filename.endswith(DiscordModule._IMAGE_SUFFIXES)
):
images.append(
IncomingMessage.MessageImage(
ref=url,
name=attachment.get("filename"),
mime_type=attachment.get("content_type"),
size=attachment.get("size"),
)
)
return images if images else None
@classmethod
def _extract_audio_refs(cls, msg_json: dict[str, Any]) -> Optional[List[str]]:
"""
从Discord消息中提取音频URL。
"""
attachments = msg_json.get("attachments", [])
if not attachments:
return None
audio_refs = []
for attachment in attachments:
url = attachment.get("url") or attachment.get("proxy_url")
if not url:
continue
content_type = (attachment.get("content_type") or "").lower()
filename = (attachment.get("filename") or "").lower()
if content_type.startswith("audio/") or filename.endswith(cls._AUDIO_SUFFIXES):
audio_refs.append(f"discord://file/{quote(url, safe='')}")
return audio_refs if audio_refs else None
@classmethod
def _extract_files(
cls, msg_json: dict[str, Any]
) -> Optional[List[IncomingMessage.MessageAttachment]]:
"""
从 Discord 消息中提取非图片/非音频文件。
"""
attachments = msg_json.get("attachments", [])
if not attachments:
return None
files = []
for attachment in attachments:
url = attachment.get("url") or attachment.get("proxy_url")
if not url:
continue
content_type = (attachment.get("content_type") or "").lower()
filename = (attachment.get("filename") or "").lower()
is_image = (
attachment.get("type") == "image"
or 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"discord://file/{quote(url, safe='')}",
name=attachment.get("filename"),
mime_type=attachment.get("content_type"),
size=attachment.get("size"),
)
)
return files or None
def download_discord_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
"""
下载 Discord 附件引用并返回原始字节。
:param file_ref: discord://file/ 开头的内部文件引用
:param source: 所属通知配置名称
:return: 非空文件内容;引用、配置或下载不可用时返回 None
"""
if not file_ref or not file_ref.startswith("discord://file/"):
return None
if not self.get_config(source):
return None
file_url = unquote(file_ref.replace("discord://file/", "", 1))
resp = RequestUtils(timeout=30).get_res(file_url)
if resp and resp.content:
content = resp.content
return content if isinstance(content, bytes) else None
return None
def post_message(self, message: Message, **kwargs: Any) -> None:
"""
向匹配的 Discord 配置发送普通通知。
不汇总客户端发送结果;客户端负责记录失败。
:param message: 提供通知范围、接收人和原消息信息的通知对象
:param kwargs: 兼容统一接口的扩展参数,当前不使用
"""
configs = self.get_configs()
logger.debug(
f"[Discord] post_message 被调用,message.source={message.source}, "
f"message.userid={message.userid}, message.channel={message.channel}"
)
logger.debug(
f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}"
)
logger.debug(
f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}"
)
if not configs:
logger.debug("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
return
for conf in configs.values():
logger.debug(
f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}"
)
if not self.check_message(message, conf.name):
logger.debug(
f"[Discord] check_message 返回 False,跳过配置: {conf.name}"
)
continue
logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}")
targets = self.get_notification_targets(message)
userid = message.userid
if not userid and targets is not None:
userid = targets.get("discord_userid")
if not userid:
logger.warn("用户没有指定 Discord 用户ID,消息无法发送")
return
client: Optional[Discord] = self.get_instance(conf.name)
logger.debug(
f"[Discord] get_instance('{conf.name}') 返回: {client is not None}"
)
if client:
logger.debug(
f"[Discord] 调用 client 发送, userid={userid}, title={message.title[:50] if message.title else None}..."
)
if message.file_path:
result = client.send_file(
file_path=message.file_path,
file_name=message.file_name,
title=message.title,
text=message.text,
userid=userid,
original_chat_id=message.original_chat_id,
)
else:
texts: Sequence[Optional[str]] = message.channel_text_chunks(NotificationChannel.Discord) or [message.text]
for text in texts:
result = client.send_msg(
title=message.title,
text=text,
image=message.image,
userid=userid,
link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
mtype=message.mtype,
)
logger.debug(f"[Discord] send_msg 返回结果: {result}")
else:
logger.warning(
f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例"
)
def post_medias_message(
self, message: Message, medias: List[MediaInfo]
) -> None:
"""
向匹配的 Discord 配置发送媒体候选列表。
不汇总客户端发送结果;客户端负责记录失败。
:param message: 提供通知范围、接收人和原消息信息的通知对象
:param medias: 待展示的媒体列表
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
client: Optional[Discord] = self.get_instance(conf.name)
if client:
client.send_medias_msg(
title=message.title,
medias=medias,
userid=self.get_notification_userid(message, "discord_userid"),
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def post_torrents_message(
self, message: Message, torrents: List[Context]
) -> None:
"""
向匹配的 Discord 配置发送种子候选列表。
不汇总客户端发送结果;客户端负责记录失败。
:param message: 提供通知范围、接收人和原消息信息的通知对象
:param torrents: 待展示的种子上下文列表
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
client: Optional[Discord] = self.get_instance(conf.name)
if client:
client.send_torrents_msg(
title=message.title,
torrents=torrents,
userid=self.get_notification_userid(message, "discord_userid"),
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
)
def delete_message(
self,
channel: NotificationChannel,
source: str,
message_id: str,
chat_id: Optional[str] = None,
) -> Optional[bool]:
"""
删除消息。
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: Discord 数字消息标识
:param chat_id: 聊天ID(频道ID)
:return: 删除成功返回 True,失败返回 False;非 Discord 渠道返回 None
"""
if channel != self._channel:
return None
success = False
for conf in self.get_configs().values():
if source != conf.name:
continue
client: Optional[Discord] = self.get_instance(conf.name)
if client:
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
if result:
success = True
return success
def edit_message(
self,
channel: NotificationChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
buttons: Optional[List[List[dict[str, Any]]]] = None,
metadata: Optional[dict[str, Any]] = None,
) -> Optional[bool]:
"""
编辑消息。
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息ID
:param chat_id: 聊天ID
:param text: 新的消息内容
:param title: 消息标题
:param buttons: 新的按钮列表
:param metadata: 兼容统一接口的元数据,当前不使用
:return: 客户端明确成功时为 True;未处理或失败时为 False,非 Discord 渠道为 None
"""
if channel != self._channel:
return None
for conf in self.get_configs().values():
if source != conf.name:
continue
client: Optional[Discord] = self.get_instance(conf.name)
if client:
result = client.send_msg(
title=title or "",
text=text,
buttons=buttons,
original_message_id=message_id,
original_chat_id=str(chat_id),
)
if isinstance(result, tuple):
if result and result[0]:
return True
elif result:
return True
return False
def mark_message_processing_started(
self,
channel: NotificationChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
启动会话正在输入状态。
:param channel: 目标通知渠道;非 Discord 时不处理
:param source: 通知配置名称
:param userid: 用于定位会话的用户标识
:param message_id: 原消息标识,仅随状态保存,不参与输入状态定位
:param chat_id: 优先使用的会话频道标识
:param text: 原消息文本;为空时不启动输入状态
:return: 成功时返回供结束阶段清理使用的状态字典;不适用或失败时返回 None
"""
if channel != self._channel:
return None
if not text:
return None
config = self.get_config(source)
if not config:
return None
client: Optional[Discord] = self.get_instance(config.name)
if not client:
return None
if not client.start_typing(
userid=str(userid) if userid else None,
chat_id=str(chat_id) if chat_id else None,
):
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": str(message_id) if message_id else None,
"chat_id": str(chat_id) if chat_id else None,
"metadata": {"kind": "typing"},
}
def mark_message_processing_finished(
self,
channel: NotificationChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict[str, Any]] = None,
) -> Optional[bool]:
"""
请求停止会话正在输入状态。
:param channel: 目标通知渠道;非 Discord 时不处理
:param source: 通知配置名称
:param userid: 用于定位会话的用户标识
:param message_id: 原消息标识,仅随状态保存,不参与输入状态定位
:param chat_id: 优先使用的会话频道标识
:param status: 开始阶段返回的状态,优先使用其中的用户与频道
:return: 找到既有任务并请求停止时返回 True,失败返回 False;非 Discord 渠道返回 None
"""
if channel != self._channel:
return None
target_chat_id = (status or {}).get("chat_id") or chat_id
target_userid = (status or {}).get("userid") or userid
config = self.get_config(source)
if not config:
return False
client: Optional[Discord] = self.get_instance(config.name)
if not client:
return False
return bool(client.stop_typing(
userid=str(target_userid) if target_userid else None,
chat_id=str(target_chat_id) if target_chat_id else None,
))
def send_direct_message(self, message: Message) -> Optional[MessageResponse]:
"""
向匹配的 Discord 通知实例直接发送文件或文本。
:param message: 提供接收对象、文件或文本内容的通知消息
:return: 首次成功发送的消息响应;无可用接收人或全部失败时返回 None
"""
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 is not None:
userid = targets.get("discord_userid")
if not userid:
logger.warn("用户没有指定 Discord 用户ID,消息无法发送")
return None
client: Optional[Discord] = self.get_instance(conf.name)
if client:
if message.file_path:
result = client.send_file(
file_path=message.file_path,
file_name=message.file_name,
title=message.title,
text=message.text,
userid=userid,
)
else:
result = client.send_msg(
title=message.title or "",
text=message.text,
userid=userid,
)
if result:
success, response_data = (
(result[0], result[1])
if isinstance(result, tuple)
else (result, None)
)
if success:
message_id = None
chat_id = None
if isinstance(response_data, dict):
message_id = response_data.get("message_id")
chat_id = response_data.get("chat_id")
elif response_data is not None:
message_id = str(response_data)
return MessageResponse(
message_id=str(message_id) if message_id else None,
chat_id=str(chat_id) if chat_id else None,
channel=NotificationChannel.Discord,
source=conf.name,
success=True,
)
return None