mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
更新Discord模块支持互动消息
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
|
import json
|
||||||
from typing import Optional, Union, List, Tuple, Any
|
from typing import Optional, Union, List, Tuple, Any
|
||||||
|
|
||||||
from app.core.context import MediaInfo, Context
|
from app.core.context import MediaInfo, Context
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase, _MessageBase
|
from app.modules import _ModuleBase, _MessageBase
|
||||||
from app.modules.discord.discord import Discord
|
|
||||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||||
from app.schemas.types import ModuleType
|
from app.schemas.types import ModuleType
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.modules.discord.discord import Discord
|
||||||
|
except Exception as err: # ImportError or other load issues
|
||||||
|
Discord = None
|
||||||
|
logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}")
|
||||||
|
|
||||||
|
|
||||||
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||||
|
|
||||||
@@ -14,6 +20,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
"""
|
"""
|
||||||
初始化模块
|
初始化模块
|
||||||
"""
|
"""
|
||||||
|
if not Discord:
|
||||||
|
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
|
||||||
|
return
|
||||||
super().init_service(service_name=Discord.__name__.lower(),
|
super().init_service(service_name=Discord.__name__.lower(),
|
||||||
service_type=Discord)
|
service_type=Discord)
|
||||||
self._channel = MessageChannel.Discord
|
self._channel = MessageChannel.Discord
|
||||||
@@ -59,7 +68,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
for name, client in self.get_instances().items():
|
for name, client in self.get_instances().items():
|
||||||
state = client.get_state()
|
state = client.get_state()
|
||||||
if not state:
|
if not state:
|
||||||
return False, f"Discord {name} webhook URL 未配置"
|
return False, f"Discord {name} Bot 未就绪"
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
@@ -77,8 +86,51 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
:param args: 参数
|
:param args: 参数
|
||||||
:return: 渠道、消息体
|
:return: 渠道、消息体
|
||||||
"""
|
"""
|
||||||
# Discord 模块暂时不支持接收消息
|
client_config = self.get_config(source)
|
||||||
|
if not client_config:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
msg_json: dict = json.loads(body)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"解析 Discord 消息失败:{str(err)}")
|
||||||
|
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:
|
||||||
|
logger.info(f"收到来自 {client_config.name} 的 Discord 按钮回调:"
|
||||||
|
f"userid={userid}, username={username}, callback_data={callback_data}")
|
||||||
|
return CommingMessage(
|
||||||
|
channel=MessageChannel.Discord,
|
||||||
|
source=client_config.name,
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
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")
|
||||||
|
if text and userid:
|
||||||
|
logger.info(f"收到来自 {client_config.name} 的 Discord 消息:"
|
||||||
|
f"userid={userid}, username={username}, text={text}")
|
||||||
|
return CommingMessage(channel=MessageChannel.Discord, source=client_config.name,
|
||||||
|
userid=userid, username=username, text=text,
|
||||||
|
chat_id=str(chat_id) if chat_id else None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def post_message(self, message: Notification, **kwargs) -> None:
|
def post_message(self, message: Notification, **kwargs) -> None:
|
||||||
@@ -89,10 +141,17 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
for conf in self.get_configs().values():
|
for conf in self.get_configs().values():
|
||||||
if not self.check_message(message, conf.name):
|
if not self.check_message(message, conf.name):
|
||||||
continue
|
continue
|
||||||
|
targets = message.targets
|
||||||
|
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: Discord = self.get_instance(conf.name)
|
client: Discord = self.get_instance(conf.name)
|
||||||
if client:
|
if client:
|
||||||
client.send_msg(title=message.title, text=message.text,
|
client.send_msg(title=message.title, text=message.text,
|
||||||
image=message.image, userid=message.userid, link=message.link,
|
image=message.image, userid=userid, link=message.link,
|
||||||
buttons=message.buttons,
|
buttons=message.buttons,
|
||||||
original_message_id=message.original_message_id,
|
original_message_id=message.original_message_id,
|
||||||
original_chat_id=message.original_chat_id)
|
original_chat_id=message.original_chat_id)
|
||||||
@@ -104,8 +163,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
:param medias: 媒体信息
|
:param medias: 媒体信息
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
logger.warn("Discord webhooks 不支持")
|
for conf in self.get_configs().values():
|
||||||
return None
|
if not self.check_message(message, conf.name):
|
||||||
|
continue
|
||||||
|
client: Discord = self.get_instance(conf.name)
|
||||||
|
if client:
|
||||||
|
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,
|
||||||
|
buttons=message.buttons,
|
||||||
|
original_message_id=message.original_message_id,
|
||||||
|
original_chat_id=message.original_chat_id)
|
||||||
|
|
||||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -114,8 +180,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
:param torrents: 种子信息
|
:param torrents: 种子信息
|
||||||
:return: 成功或失败
|
:return: 成功或失败
|
||||||
"""
|
"""
|
||||||
logger.warn("Discord webhooks 不支持")
|
for conf in self.get_configs().values():
|
||||||
return False
|
if not self.check_message(message, conf.name):
|
||||||
|
continue
|
||||||
|
client: Discord = self.get_instance(conf.name)
|
||||||
|
if client:
|
||||||
|
client.send_torrents_msg(title=message.title, torrents=torrents,
|
||||||
|
userid=message.userid, buttons=message.buttons,
|
||||||
|
original_message_id=message.original_message_id,
|
||||||
|
original_chat_id=message.original_chat_id)
|
||||||
|
|
||||||
def delete_message(self, channel: MessageChannel, source: str,
|
def delete_message(self, channel: MessageChannel, source: str,
|
||||||
message_id: str, chat_id: Optional[str] = None) -> bool:
|
message_id: str, chat_id: Optional[str] = None) -> bool:
|
||||||
@@ -127,5 +200,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
:param chat_id: 聊天ID(频道ID)
|
:param chat_id: 聊天ID(频道ID)
|
||||||
:return: 删除是否成功
|
:return: 删除是否成功
|
||||||
"""
|
"""
|
||||||
logger.warn("Discord webhooks 不支持")
|
success = False
|
||||||
return False
|
for conf in self.get_configs().values():
|
||||||
|
if channel != self._channel:
|
||||||
|
break
|
||||||
|
if source != conf.name:
|
||||||
|
continue
|
||||||
|
client: 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
|
||||||
|
|||||||
+475
-110
@@ -1,148 +1,513 @@
|
|||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from typing import Optional, List, Dict
|
import threading
|
||||||
|
from typing import Optional, List, Dict, Any, Union
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo, Context
|
from app.core.context import MediaInfo, Context
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.helper.image import ImageHelper
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.http import RequestUtils
|
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
|
||||||
class Discord:
|
class Discord:
|
||||||
"""
|
"""
|
||||||
Discord Webhook通知实现
|
Discord Bot 通知与交互实现(基于 discord.py 2.6.4)
|
||||||
"""
|
"""
|
||||||
_webhook_url: Optional[str] = None
|
|
||||||
_username: Optional[str] = None
|
|
||||||
_avatar_url: Optional[str] = None
|
|
||||||
|
|
||||||
def __init__(self, DISCORD_WEBHOOK_URL: Optional[str] = None,
|
def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None,
|
||||||
DISCORD_USERNAME: Optional[str] = None,
|
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
|
||||||
DISCORD_AVATAR_URL: Optional[str] = None, **kwargs):
|
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
|
||||||
"""
|
**kwargs):
|
||||||
初始化Discord webhook客户端
|
if not DISCORD_BOT_TOKEN:
|
||||||
:param DISCORD_WEBHOOK_URL: Discord webhook URL
|
logger.error("Discord Bot Token 未配置!")
|
||||||
:param DISCORD_USERNAME: 自定义webhook消息的用户名
|
|
||||||
:param DISCORD_AVATAR_URL: 自定义webhook消息的头像URL
|
|
||||||
"""
|
|
||||||
if not DISCORD_WEBHOOK_URL:
|
|
||||||
logger.error("Discord webhook URL未配置!")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._webhook_url = DISCORD_WEBHOOK_URL
|
self._token = DISCORD_BOT_TOKEN
|
||||||
self._username = DISCORD_USERNAME or "MoviePilot"
|
self._guild_id = self._to_int(DISCORD_GUILD_ID)
|
||||||
self._avatar_url = DISCORD_AVATAR_URL
|
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
|
||||||
|
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
|
||||||
|
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
|
||||||
|
if kwargs.get("name"):
|
||||||
|
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
intents.messages = True
|
||||||
|
intents.guilds = True
|
||||||
|
|
||||||
|
self._client: Optional[discord.Client] = discord.Client(intents=intents)
|
||||||
|
self._tree: Optional[app_commands.CommandTree] = None
|
||||||
|
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._ready_event = threading.Event()
|
||||||
|
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
|
||||||
|
self._broadcast_channel = None
|
||||||
|
self._bot_user_id: Optional[int] = None
|
||||||
|
|
||||||
|
self._register_events()
|
||||||
|
self._start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_int(val: Optional[Union[str, int]]) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(val) if val is not None and str(val).strip() else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _register_events(self):
|
||||||
|
@self._client.event
|
||||||
|
async def on_ready():
|
||||||
|
self._bot_user_id = self._client.user.id if self._client.user else None
|
||||||
|
self._ready_event.set()
|
||||||
|
logger.info(f"Discord Bot 已登录:{self._client.user}")
|
||||||
|
|
||||||
|
@self._client.event
|
||||||
|
async def on_message(message: discord.Message):
|
||||||
|
if message.author.bot:
|
||||||
|
return
|
||||||
|
if not self._should_process_message(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
cleaned_text = self._clean_bot_mention(message.content or "")
|
||||||
|
username = message.author.display_name or message.author.global_name or message.author.name
|
||||||
|
payload = {
|
||||||
|
"type": "message",
|
||||||
|
"userid": str(message.author.id),
|
||||||
|
"username": username,
|
||||||
|
"user_tag": str(message.author),
|
||||||
|
"text": cleaned_text,
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"chat_id": str(message.channel.id),
|
||||||
|
"channel_type": "dm" if isinstance(message.channel, discord.DMChannel) else "guild"
|
||||||
|
}
|
||||||
|
await self._post_to_ds(payload)
|
||||||
|
|
||||||
|
@self._client.event
|
||||||
|
async def on_interaction(interaction: discord.Interaction):
|
||||||
|
if interaction.type == discord.InteractionType.component:
|
||||||
|
data = interaction.data or {}
|
||||||
|
callback_data = data.get("custom_id")
|
||||||
|
if not callback_data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
|
||||||
|
if interaction.user else None
|
||||||
|
payload = {
|
||||||
|
"type": "interaction",
|
||||||
|
"userid": str(interaction.user.id) if interaction.user else None,
|
||||||
|
"username": username,
|
||||||
|
"user_tag": str(interaction.user) if interaction.user else None,
|
||||||
|
"callback_data": callback_data,
|
||||||
|
"message_id": str(interaction.message.id) if interaction.message else None,
|
||||||
|
"chat_id": str(interaction.channel.id) if interaction.channel else None
|
||||||
|
}
|
||||||
|
await self._post_to_ds(payload)
|
||||||
|
|
||||||
|
def _start(self):
|
||||||
|
if self._thread:
|
||||||
|
return
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
try:
|
||||||
|
self._loop.create_task(self._client.start(self._token))
|
||||||
|
self._loop.run_forever()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Discord Bot 启动失败:{err}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._loop.run_until_complete(self._client.close())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=runner, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self._client or not self._loop or not self._thread:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(timeout=10)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"关闭 Discord Bot 失败:{err}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._ready_event.clear()
|
||||||
|
|
||||||
def get_state(self) -> bool:
|
def get_state(self) -> bool:
|
||||||
"""
|
return self._ready_event.is_set() and self._client is not None
|
||||||
获取服务状态
|
|
||||||
:return: Webhook URL已配置则返回True
|
|
||||||
"""
|
|
||||||
return self._webhook_url is not None
|
|
||||||
|
|
||||||
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
|
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
|
||||||
userid: Optional[str] = None, link: Optional[str] = None,
|
userid: Optional[str] = None, link: Optional[str] = None,
|
||||||
buttons: Optional[List[List[dict]]] = None,
|
buttons: Optional[List[List[dict]]] = None,
|
||||||
original_message_id: Optional[int] = None,
|
original_message_id: Optional[Union[int, str]] = None,
|
||||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||||
"""
|
if not self.get_state():
|
||||||
通过webhook发送Discord消息
|
return False
|
||||||
:param title: 消息标题
|
|
||||||
:param text: 消息内容
|
|
||||||
:param image: 消息图片URL
|
|
||||||
:param userid: 用户ID(webhook不使用)
|
|
||||||
:param link: 跳转链接
|
|
||||||
:param buttons: 按钮列表(基础webhook不支持)
|
|
||||||
:param original_message_id: 原消息ID(不支持编辑)
|
|
||||||
:param original_chat_id: 原聊天ID(不支持编辑)
|
|
||||||
:return: 成功或失败
|
|
||||||
"""
|
|
||||||
if not self._webhook_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not title and not text:
|
if not title and not text:
|
||||||
logger.warn("标题和内容不能同时为空")
|
logger.warn("标题和内容不能同时为空")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 解析消息内容,构建 fields 数组
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
fields = []
|
self._send_message(title=title, text=text, image=image, userid=userid,
|
||||||
converted_text = ' '
|
link=link, buttons=buttons,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id),
|
||||||
|
self._loop)
|
||||||
|
return future.result(timeout=30)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"发送 Discord 消息失败:{err}")
|
||||||
|
return False
|
||||||
|
|
||||||
if text:
|
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
|
||||||
# 按逗号分割消息内容
|
buttons: Optional[List[List[dict]]] = None,
|
||||||
lines = text.splitlines()
|
original_message_id: Optional[Union[int, str]] = None,
|
||||||
# 遍历每行内容
|
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||||
for line in lines:
|
if not self.get_state() or not medias:
|
||||||
# 将每行内容按冒号分割为字段名称和值
|
return False
|
||||||
if ':' not in line:
|
title = title or "媒体列表"
|
||||||
converted_text = line
|
try:
|
||||||
else:
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
name, value = line.split(':', 1)
|
self._send_list_message(
|
||||||
# 创建一个字典表示一个 field
|
embeds=self._build_media_embeds(medias, title),
|
||||||
field = {
|
userid=userid,
|
||||||
"name": name.strip(),
|
buttons=self._build_default_buttons(len(medias)) if not buttons else buttons,
|
||||||
"value": value.strip(),
|
fallback_buttons=buttons,
|
||||||
"inline": False
|
original_message_id=original_message_id,
|
||||||
}
|
original_chat_id=original_chat_id
|
||||||
# 将 field 添加到 fields 列表中
|
),
|
||||||
fields.append(field)
|
self._loop
|
||||||
|
|
||||||
# 构建 embed
|
|
||||||
embed = {
|
|
||||||
"title": title,
|
|
||||||
"url": link if link else "https://github.com/jxxghp/MoviePilot",
|
|
||||||
"color": 15258703,
|
|
||||||
"description": converted_text if converted_text else text,
|
|
||||||
"fields": fields
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加图片
|
|
||||||
if image:
|
|
||||||
# 获取并验证图片
|
|
||||||
image_content = ImageHelper().fetch_image(image)
|
|
||||||
if image_content:
|
|
||||||
embed["image"] = {
|
|
||||||
"url": image
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
logger.warn(f"获取图片失败: {image},将不带图片发送")
|
|
||||||
|
|
||||||
# 构建payload
|
|
||||||
payload = {
|
|
||||||
"username": self._username,
|
|
||||||
"embeds": [embed]
|
|
||||||
}
|
|
||||||
|
|
||||||
# 添加自定义头像
|
|
||||||
if self._avatar_url:
|
|
||||||
payload["avatar_url"] = self._avatar_url
|
|
||||||
|
|
||||||
# 发送webhook请求
|
|
||||||
response = RequestUtils(
|
|
||||||
timeout=10,
|
|
||||||
content_type="application/json"
|
|
||||||
).post_res(
|
|
||||||
url=self._webhook_url,
|
|
||||||
json=payload
|
|
||||||
)
|
)
|
||||||
|
return future.result(timeout=30)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"发送 Discord 媒体列表失败:{err}")
|
||||||
|
return False
|
||||||
|
|
||||||
if response and response.status_code == 204:
|
def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,
|
||||||
# logger.info("Discord消息发送成功")
|
buttons: Optional[List[List[dict]]] = None,
|
||||||
|
original_message_id: Optional[Union[int, str]] = None,
|
||||||
|
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||||
|
if not self.get_state() or not torrents:
|
||||||
|
return False
|
||||||
|
title = title or "种子列表"
|
||||||
|
try:
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self._send_list_message(
|
||||||
|
embeds=self._build_torrent_embeds(torrents, title),
|
||||||
|
userid=userid,
|
||||||
|
buttons=self._build_default_buttons(len(torrents)) if not buttons else buttons,
|
||||||
|
fallback_buttons=buttons,
|
||||||
|
original_message_id=original_message_id,
|
||||||
|
original_chat_id=original_chat_id
|
||||||
|
),
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
return future.result(timeout=30)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"发送 Discord 种子列表失败:{err}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_msg(self, message_id: Union[str, int], chat_id: Optional[str] = None) -> Optional[bool]:
|
||||||
|
if not self.get_state():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self._delete_message(message_id=message_id, chat_id=chat_id),
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
return future.result(timeout=15)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"删除 Discord 消息失败:{err}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _send_message(self, title: str, text: Optional[str], image: Optional[str],
|
||||||
|
userid: Optional[str], link: Optional[str],
|
||||||
|
buttons: Optional[List[List[dict]]],
|
||||||
|
original_message_id: Optional[Union[int, str]],
|
||||||
|
original_chat_id: Optional[str]) -> bool:
|
||||||
|
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
|
||||||
|
if not channel:
|
||||||
|
logger.error("未找到可用的 Discord 频道或私聊")
|
||||||
|
return False
|
||||||
|
|
||||||
|
embed = self._build_embed(title=title, text=text, image=image, link=link)
|
||||||
|
view = self._build_view(buttons=buttons, link=link)
|
||||||
|
content = None
|
||||||
|
|
||||||
|
if original_message_id and original_chat_id:
|
||||||
|
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
|
||||||
|
content=content, embed=embed, view=view)
|
||||||
|
|
||||||
|
await channel.send(content=content, embed=embed, view=view)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _send_list_message(self, embeds: List[discord.Embed],
|
||||||
|
userid: Optional[str],
|
||||||
|
buttons: Optional[List[List[dict]]],
|
||||||
|
fallback_buttons: Optional[List[List[dict]]],
|
||||||
|
original_message_id: Optional[Union[int, str]],
|
||||||
|
original_chat_id: Optional[str]) -> bool:
|
||||||
|
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
|
||||||
|
if not channel:
|
||||||
|
logger.error("未找到可用的 Discord 频道或私聊")
|
||||||
|
return False
|
||||||
|
|
||||||
|
view = self._build_view(buttons=buttons if buttons else fallback_buttons)
|
||||||
|
embeds = embeds[:10] if embeds else [] # Discord 单条消息最多 10 个 embed
|
||||||
|
|
||||||
|
if original_message_id and original_chat_id:
|
||||||
|
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
|
||||||
|
content=None, embed=None, view=view, embeds=embeds)
|
||||||
|
|
||||||
|
await channel.send(embed=embeds[0] if len(embeds) == 1 else None,
|
||||||
|
embeds=embeds if len(embeds) > 1 else None,
|
||||||
|
view=view)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _edit_message(self, chat_id: Union[str, int], message_id: Union[str, int],
|
||||||
|
content: Optional[str], embed: Optional[discord.Embed],
|
||||||
|
view: Optional[discord.ui.View], embeds: Optional[List[discord.Embed]] = None) -> bool:
|
||||||
|
channel = await self._resolve_channel(chat_id=str(chat_id))
|
||||||
|
if not channel:
|
||||||
|
logger.error(f"未找到要编辑的 Discord 频道:{chat_id}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
message = await channel.fetch_message(int(message_id))
|
||||||
|
kwargs: Dict[str, Any] = {"content": content, "view": view}
|
||||||
|
if embeds:
|
||||||
|
if len(embeds) == 1:
|
||||||
|
kwargs["embed"] = embeds[0]
|
||||||
else:
|
else:
|
||||||
logger.error(f"Discord消息发送失败: {response.status_code if response else 'No response'}")
|
kwargs["embeds"] = embeds
|
||||||
|
elif embed:
|
||||||
|
kwargs["embed"] = embed
|
||||||
|
await message.edit(**kwargs)
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"编辑 Discord 消息失败:{err}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool:
|
||||||
logger.error(f"发送Discord消息时出现异常: {str(e)}")
|
channel = await self._resolve_channel(chat_id=chat_id)
|
||||||
|
if not channel:
|
||||||
|
logger.error("删除 Discord 消息时未找到频道")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
message = await channel.fetch_message(int(message_id))
|
||||||
|
await message.delete()
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"删除 Discord 消息失败:{err}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _build_embed(self, title: str, text: Optional[str], image: Optional[str],
|
||||||
|
link: Optional[str]) -> discord.Embed:
|
||||||
|
description = ""
|
||||||
|
fields: List[Dict[str, str]] = []
|
||||||
|
if text:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
name, value = line.split(":", 1)
|
||||||
|
fields.append({"name": name.strip(), "value": value.strip() or "-", "inline": False})
|
||||||
|
else:
|
||||||
|
description += f"{line}\n"
|
||||||
|
description = description.strip()
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=title,
|
||||||
|
url=link or "https://github.com/jxxghp/MoviePilot",
|
||||||
|
description=description if description else (text or None),
|
||||||
|
color=0xE67E22
|
||||||
|
)
|
||||||
|
for field in fields:
|
||||||
|
embed.add_field(name=field["name"], value=field["value"], inline=False)
|
||||||
|
if image:
|
||||||
|
embed.set_image(url=image)
|
||||||
|
return embed
|
||||||
|
|
||||||
def stop(self):
|
def _build_media_embeds(self, medias: List[MediaInfo], title: str) -> List[discord.Embed]:
|
||||||
"""
|
embeds: List[discord.Embed] = []
|
||||||
停止Discord服务(webhook无需清理)
|
for index, media in enumerate(medias[:10], start=1):
|
||||||
"""
|
overview = media.get_overview_string(80)
|
||||||
|
desc_parts = [
|
||||||
|
f"{media.type.value} | {media.vote_star}" if media.vote_star else media.type.value,
|
||||||
|
overview
|
||||||
|
]
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{index}. {media.title_year}",
|
||||||
|
url=media.detail_link or discord.Embed.Empty,
|
||||||
|
description="\n".join([p for p in desc_parts if p]),
|
||||||
|
color=0x5865F2
|
||||||
|
)
|
||||||
|
if media.get_poster_image():
|
||||||
|
embed.set_thumbnail(url=media.get_poster_image())
|
||||||
|
embeds.append(embed)
|
||||||
|
if embeds:
|
||||||
|
embeds[0].set_author(name=title)
|
||||||
|
return embeds
|
||||||
|
|
||||||
|
def _build_torrent_embeds(self, torrents: List[Context], title: str) -> List[discord.Embed]:
|
||||||
|
embeds: List[discord.Embed] = []
|
||||||
|
for index, context in enumerate(torrents[:10], start=1):
|
||||||
|
torrent = context.torrent_info
|
||||||
|
meta = MetaInfo(torrent.title, torrent.description)
|
||||||
|
title_text = f"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}"
|
||||||
|
title_text = re.sub(r"\s+", " ", title_text).strip()
|
||||||
|
detail = [
|
||||||
|
f"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}↑",
|
||||||
|
meta.resource_term,
|
||||||
|
meta.video_term
|
||||||
|
]
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{index}. {title_text or torrent.title}",
|
||||||
|
url=torrent.page_url or discord.Embed.Empty,
|
||||||
|
description="\n".join([d for d in detail if d]),
|
||||||
|
color=0x00A86B
|
||||||
|
)
|
||||||
|
poster = getattr(torrent, "poster", None)
|
||||||
|
if poster:
|
||||||
|
embed.set_thumbnail(url=poster)
|
||||||
|
embeds.append(embed)
|
||||||
|
if embeds:
|
||||||
|
embeds[0].set_author(name=title)
|
||||||
|
return embeds
|
||||||
|
|
||||||
|
def _build_default_buttons(self, count: int) -> List[List[dict]]:
|
||||||
|
buttons: List[List[dict]] = []
|
||||||
|
max_rows = 5
|
||||||
|
max_per_row = 5
|
||||||
|
capped = min(count, max_rows * max_per_row)
|
||||||
|
for idx in range(1, capped + 1):
|
||||||
|
row_idx = (idx - 1) // max_per_row
|
||||||
|
if len(buttons) <= row_idx:
|
||||||
|
buttons.append([])
|
||||||
|
buttons[row_idx].append({"text": f"选择 {idx}", "callback_data": str(idx)})
|
||||||
|
if count > capped:
|
||||||
|
logger.warn(f"按钮数量超过 Discord 限制,仅展示前 {capped} 个")
|
||||||
|
return buttons
|
||||||
|
|
||||||
|
def _build_view(self, buttons: Optional[List[List[dict]]], link: Optional[str] = None) -> Optional[discord.ui.View]:
|
||||||
|
has_buttons = buttons and any(buttons)
|
||||||
|
if not has_buttons and not link:
|
||||||
|
return None
|
||||||
|
|
||||||
|
view = discord.ui.View(timeout=None)
|
||||||
|
if buttons:
|
||||||
|
for row_index, button_row in enumerate(buttons[:5]):
|
||||||
|
for button in button_row[:5]:
|
||||||
|
if "url" in button:
|
||||||
|
btn = discord.ui.Button(label=button.get("text", "链接"),
|
||||||
|
url=button["url"],
|
||||||
|
style=discord.ButtonStyle.link)
|
||||||
|
else:
|
||||||
|
custom_id = (button.get("callback_data") or button.get("text") or f"btn-{row_index}")[:99]
|
||||||
|
btn = discord.ui.Button(label=button.get("text", "选择")[:80],
|
||||||
|
custom_id=custom_id,
|
||||||
|
style=discord.ButtonStyle.primary)
|
||||||
|
view.add_item(btn)
|
||||||
|
elif link:
|
||||||
|
view.add_item(discord.ui.Button(label="查看详情", url=link, style=discord.ButtonStyle.link))
|
||||||
|
return view
|
||||||
|
|
||||||
|
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
|
||||||
|
# 优先使用明确的聊天 ID
|
||||||
|
if chat_id:
|
||||||
|
channel = self._client.get_channel(int(chat_id))
|
||||||
|
if channel:
|
||||||
|
return channel
|
||||||
|
try:
|
||||||
|
return await self._client.fetch_channel(int(chat_id))
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 私聊
|
||||||
|
if userid:
|
||||||
|
dm = await self._get_dm_channel(str(userid))
|
||||||
|
if dm:
|
||||||
|
return dm
|
||||||
|
|
||||||
|
# 配置的广播频道
|
||||||
|
if self._broadcast_channel:
|
||||||
|
return self._broadcast_channel
|
||||||
|
if self._channel_id:
|
||||||
|
channel = self._client.get_channel(self._channel_id)
|
||||||
|
if not channel:
|
||||||
|
try:
|
||||||
|
channel = await self._client.fetch_channel(self._channel_id)
|
||||||
|
except Exception:
|
||||||
|
channel = None
|
||||||
|
self._broadcast_channel = channel
|
||||||
|
if channel:
|
||||||
|
return channel
|
||||||
|
|
||||||
|
# 按 Guild 寻找一个可用文本频道
|
||||||
|
target_guilds = []
|
||||||
|
if self._guild_id:
|
||||||
|
guild = self._client.get_guild(self._guild_id)
|
||||||
|
if guild:
|
||||||
|
target_guilds.append(guild)
|
||||||
|
else:
|
||||||
|
target_guilds = list(self._client.guilds)
|
||||||
|
|
||||||
|
for guild in target_guilds:
|
||||||
|
for channel in guild.text_channels:
|
||||||
|
if guild.me and channel.permissions_for(guild.me).send_messages:
|
||||||
|
self._broadcast_channel = channel
|
||||||
|
return channel
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
|
||||||
|
if userid in self._user_dm_cache:
|
||||||
|
return self._user_dm_cache.get(userid)
|
||||||
|
try:
|
||||||
|
user_obj = self._client.get_user(int(userid)) or await self._client.fetch_user(int(userid))
|
||||||
|
if not user_obj:
|
||||||
|
return None
|
||||||
|
dm = user_obj.dm_channel or await user_obj.create_dm()
|
||||||
|
if dm:
|
||||||
|
self._user_dm_cache[userid] = dm
|
||||||
|
return dm
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"获取 Discord 私聊失败:{err}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_process_message(self, message: discord.Message) -> bool:
|
||||||
|
if isinstance(message.channel, discord.DMChannel):
|
||||||
|
return True
|
||||||
|
content = message.content or ""
|
||||||
|
# 仅处理 @Bot 或斜杠命令
|
||||||
|
if self._client.user and self._client.user.mentioned_in(message):
|
||||||
|
return True
|
||||||
|
if content.startswith("/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _clean_bot_mention(self, content: str) -> str:
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
if self._bot_user_id:
|
||||||
|
mention_pattern = rf"<@!?{self._bot_user_id}>"
|
||||||
|
content = re.sub(mention_pattern, "", content).strip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def _post_to_ds(self, payload: Dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
proxy = None
|
||||||
|
if settings.PROXY:
|
||||||
|
proxy = settings.PROXY.get("https") or settings.PROXY.get("http")
|
||||||
|
async with httpx.AsyncClient(timeout=10, verify=False, proxy=proxy) as client:
|
||||||
|
await client.post(self._ds_url, json=payload)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"转发 Discord 消息失败:{err}")
|
||||||
|
|||||||
@@ -221,6 +221,22 @@ class ChannelCapabilityManager:
|
|||||||
max_button_text_length=25,
|
max_button_text_length=25,
|
||||||
fallback_enabled=True
|
fallback_enabled=True
|
||||||
),
|
),
|
||||||
|
MessageChannel.Discord: ChannelCapabilities(
|
||||||
|
channel=MessageChannel.Discord,
|
||||||
|
capabilities={
|
||||||
|
ChannelCapability.INLINE_BUTTONS,
|
||||||
|
ChannelCapability.MESSAGE_EDITING,
|
||||||
|
ChannelCapability.MESSAGE_DELETION,
|
||||||
|
ChannelCapability.CALLBACK_QUERIES,
|
||||||
|
ChannelCapability.RICH_TEXT,
|
||||||
|
ChannelCapability.IMAGES,
|
||||||
|
ChannelCapability.LINKS
|
||||||
|
},
|
||||||
|
max_buttons_per_row=5,
|
||||||
|
max_button_rows=5,
|
||||||
|
max_button_text_length=80,
|
||||||
|
fallback_enabled=True
|
||||||
|
),
|
||||||
MessageChannel.SynologyChat: ChannelCapabilities(
|
MessageChannel.SynologyChat: ChannelCapabilities(
|
||||||
channel=MessageChannel.SynologyChat,
|
channel=MessageChannel.SynologyChat,
|
||||||
capabilities={
|
capabilities={
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ cf_clearance~=0.31.0
|
|||||||
torrentool~=1.2.0
|
torrentool~=1.2.0
|
||||||
slack-bolt~=1.23.0
|
slack-bolt~=1.23.0
|
||||||
slack-sdk~=3.35.0
|
slack-sdk~=3.35.0
|
||||||
|
discord.py==2.6.4
|
||||||
chardet~=5.2.0
|
chardet~=5.2.0
|
||||||
starlette~=0.46.2
|
starlette~=0.46.2
|
||||||
PyVirtualDisplay~=3.0
|
PyVirtualDisplay~=3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user