From bb5a657469902823cfc6554dbed7b6b624df7bf2 Mon Sep 17 00:00:00 2001 From: HankunYu Date: Mon, 22 Dec 2025 19:59:22 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Discord=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BA=92=E5=8A=A8=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/discord/__init__.py | 105 +++++- app/modules/discord/discord.py | 601 +++++++++++++++++++++++++------- app/schemas/message.py | 16 + requirements.in | 3 +- 4 files changed, 595 insertions(+), 130 deletions(-) diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index c0b7442a..745c4a81 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -1,12 +1,18 @@ +import json from typing import Optional, Union, List, Tuple, Any from app.core.context import MediaInfo, Context from app.log import logger from app.modules import _ModuleBase, _MessageBase -from app.modules.discord.discord import Discord from app.schemas import MessageChannel, CommingMessage, Notification 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]): @@ -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(), service_type=Discord) self._channel = MessageChannel.Discord @@ -59,7 +68,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): for name, client in self.get_instances().items(): state = client.get_state() if not state: - return False, f"Discord {name} webhook URL 未配置" + return False, f"Discord {name} Bot 未就绪" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: @@ -77,8 +86,51 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): :param args: 参数 :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 def post_message(self, message: Notification, **kwargs) -> None: @@ -89,10 +141,17 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): for conf in self.get_configs().values(): if not self.check_message(message, conf.name): 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) if client: 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, original_message_id=message.original_message_id, original_chat_id=message.original_chat_id) @@ -104,8 +163,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): :param medias: 媒体信息 :return: 成功或失败 """ - logger.warn("Discord webhooks 不支持") - return None + for conf in self.get_configs().values(): + 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: """ @@ -114,8 +180,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): :param torrents: 种子信息 :return: 成功或失败 """ - logger.warn("Discord webhooks 不支持") - return False + for conf in self.get_configs().values(): + 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, message_id: str, chat_id: Optional[str] = None) -> bool: @@ -127,5 +200,15 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): :param chat_id: 聊天ID(频道ID) :return: 删除是否成功 """ - logger.warn("Discord webhooks 不支持") - return False + success = 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 diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 6f2c268c..4bfa2bb7 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -1,148 +1,513 @@ +import asyncio 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.context import MediaInfo, Context from app.core.metainfo import MetaInfo -from app.helper.image import ImageHelper from app.log import logger -from app.utils.http import RequestUtils from app.utils.string import StringUtils 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, - DISCORD_USERNAME: Optional[str] = None, - DISCORD_AVATAR_URL: Optional[str] = None, **kwargs): - """ - 初始化Discord webhook客户端 - :param DISCORD_WEBHOOK_URL: Discord webhook URL - :param DISCORD_USERNAME: 自定义webhook消息的用户名 - :param DISCORD_AVATAR_URL: 自定义webhook消息的头像URL - """ - if not DISCORD_WEBHOOK_URL: - logger.error("Discord webhook URL未配置!") + def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None, + DISCORD_GUILD_ID: Optional[Union[str, int]] = None, + DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None, + **kwargs): + if not DISCORD_BOT_TOKEN: + logger.error("Discord Bot Token 未配置!") return - self._webhook_url = DISCORD_WEBHOOK_URL - self._username = DISCORD_USERNAME or "MoviePilot" - self._avatar_url = DISCORD_AVATAR_URL + self._token = DISCORD_BOT_TOKEN + self._guild_id = self._to_int(DISCORD_GUILD_ID) + 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: - """ - 获取服务状态 - :return: Webhook URL已配置则返回True - """ - return self._webhook_url is not None + return self._ready_event.is_set() and self._client is not None def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, userid: Optional[str] = None, link: Optional[str] = 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]: - """ - 通过webhook发送Discord消息 - :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 self.get_state(): + return False if not title and not text: logger.warn("标题和内容不能同时为空") return False try: - # 解析消息内容,构建 fields 数组 - fields = [] - converted_text = ' ' - - if text: - # 按逗号分割消息内容 - lines = text.splitlines() - # 遍历每行内容 - for line in lines: - # 将每行内容按冒号分割为字段名称和值 - if ':' not in line: - converted_text = line - else: - name, value = line.split(':', 1) - # 创建一个字典表示一个 field - field = { - "name": name.strip(), - "value": value.strip(), - "inline": False - } - # 将 field 添加到 fields 列表中 - fields.append(field) - - # 构建 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 - ) - - if response and response.status_code == 204: - # logger.info("Discord消息发送成功") - return True - else: - logger.error(f"Discord消息发送失败: {response.status_code if response else 'No response'}") - return False - - except Exception as e: - logger.error(f"发送Discord消息时出现异常: {str(e)}") + future = asyncio.run_coroutine_threadsafe( + self._send_message(title=title, text=text, image=image, userid=userid, + 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 + def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, + 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 medias: + return False + title = title or "媒体列表" + try: + future = asyncio.run_coroutine_threadsafe( + self._send_list_message( + embeds=self._build_media_embeds(medias, title), + userid=userid, + buttons=self._build_default_buttons(len(medias)) 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 stop(self): - """ - 停止Discord服务(webhook无需清理) - """ - pass + def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None, + 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 + + 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: + 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 + + async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool: + 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 + + 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 _build_media_embeds(self, medias: List[MediaInfo], title: str) -> List[discord.Embed]: + embeds: List[discord.Embed] = [] + 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 + + # 私聊 + 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}") diff --git a/app/schemas/message.py b/app/schemas/message.py index 6894a974..70992c13 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -221,6 +221,22 @@ class ChannelCapabilityManager: max_button_text_length=25, 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( channel=MessageChannel.SynologyChat, capabilities={ diff --git a/requirements.in b/requirements.in index 0577a75b..7ca6ad66 100644 --- a/requirements.in +++ b/requirements.in @@ -43,6 +43,7 @@ cf_clearance~=0.31.0 torrentool~=1.2.0 slack-bolt~=1.23.0 slack-sdk~=3.35.0 +discord.py==2.6.4 chardet~=5.2.0 starlette~=0.46.2 PyVirtualDisplay~=3.0 @@ -88,4 +89,4 @@ langchain-google-genai~=2.0.10 langchain-deepseek~=0.1.4 langchain-experimental~=0.3.4 openai~=1.108.2 -google-generativeai~=0.8.5 \ No newline at end of file +google-generativeai~=0.8.5