更新Discord模块支持互动消息

This commit is contained in:
HankunYu
2025-12-22 19:59:22 +00:00
parent c5fd935dd0
commit bb5a657469
4 changed files with 595 additions and 130 deletions
+93 -10
View File
@@ -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
View File
@@ -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: 用户IDwebhook不使用)
: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}")
+16
View File
@@ -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={
+1
View File
@@ -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