From d8c3e9dacfba2c9864828148e2287f0bb4b043f7 Mon Sep 17 00:00:00 2001 From: K2Cr2O1 <2221577113@qq.com> Date: Sat, 21 Mar 2026 14:26:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(discord):=20=E4=BF=AE=E5=A4=8D=20WebSocket?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E6=A3=80=E6=B5=8B=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E8=B7=A8=E5=B9=B3=E5=8F=B0=E6=96=87=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Discord WebSocket 连接检测逻辑,使用正确的属性检查连接状态 为跨平台消息处理添加文件类型支持,并增加详细的调试日志 优化附件处理逻辑,确保所有文件类型都能正确识别和转发 --- adapters/router.py | 549 +++++++++++++++++++++++++ plugins/discord-cross/handlers.py | 258 ++++++++++++ src/neobot/adapters/discord_adapter.py | 140 ++----- 3 files changed, 833 insertions(+), 114 deletions(-) create mode 100644 adapters/router.py create mode 100644 plugins/discord-cross/handlers.py diff --git a/adapters/router.py b/adapters/router.py new file mode 100644 index 0000000..245366c --- /dev/null +++ b/adapters/router.py @@ -0,0 +1,549 @@ +# -*- coding: utf-8 -*- +""" +事件路由与转换器 (Event Router & Converter) + +此模块负责在不同平台(如 Discord)和 OneBot 业务逻辑之间进行数据转换。 +核心目标是:**让现有的 OneBot 插件(如 bili.py)在不修改任何代码的情况下,能够处理 Discord 消息。** + +实现原理: +1. 接收 Discord 消息 (`discord.Message`)。 +2. 将其“伪装”成 OneBot 的 `GroupMessageEvent` 或 `PrivateMessageEvent`。 +3. 拦截插件调用的 `event.reply()` 方法。 +4. 将插件返回的 OneBot `MessageSegment` 转换为 Discord 格式并发送。 +""" +import asyncio +from typing import Union, List, Any, Optional, Dict + +try: + import discord + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + +from models.events.message import GroupMessageEvent, PrivateMessageEvent +from models.message import MessageSegment as OneBotMessageSegment +from models.sender import Sender +from core.utils.logger import ModuleLogger + +logger = ModuleLogger("EventRouter") + +class DiscordBotWrapper: + """ + 包装 DiscordAdapter,提供与 OneBot 相同的发送接口。 + """ + def __init__(self, adapter: Any): + self.adapter = adapter + self.self_id = adapter.user.id if adapter.user else 0 + + async def send_group_msg(self, group_id: int, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False): + channel = self.adapter.get_channel(group_id) + if not channel: + logger.error(f"Discord channel {group_id} not found") + return + await DiscordToOneBotConverter.send_discord_message(channel, message, self.adapter) + + async def send_private_msg(self, user_id: int, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False): + user = self.adapter.get_user(user_id) + if not user: + logger.error(f"Discord user {user_id} not found") + return + if not user.dm_channel: + await user.create_dm() + await DiscordToOneBotConverter.send_discord_message(user.dm_channel, message, self.adapter) + + async def send(self, event, message, **kwargs): + if isinstance(event, GroupMessageEvent): + await self.send_group_msg(event.group_id, message) + elif isinstance(event, PrivateMessageEvent): + await self.send_private_msg(event.user_id, message) + + def build_forward_node(self, user_id: int, nickname: str, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]]) -> Dict[str, Any]: + """ + 构建一个用于合并转发的消息节点 (Node)。 + """ + processed_message = message + if isinstance(message, OneBotMessageSegment): + processed_message = [{"type": message.type, "data": message.data}] + elif isinstance(message, list): + processed_message = [{"type": seg.type, "data": seg.data} if isinstance(seg, OneBotMessageSegment) else seg for seg in message] + + return { + "type": "node", + "data": { + "uin": user_id, + "name": nickname, + "content": processed_message + } + } + + async def send_forwarded_messages(self, target, nodes): + """ + 模拟发送合并转发消息。 + Discord 不支持像 QQ 那样的合并转发,所以我们将其转换为普通消息发送。 + """ + content = "" + files = [] + + for node in nodes: + if node.get("type") == "node": + node_data = node.get("data", {}) + node_content = node_data.get("content", []) + + if isinstance(node_content, str): + import re + cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' + matches = list(re.finditer(cq_pattern, node_content)) + + if not matches: + content += f"{node_content}\n" + else: + last_end = 0 + for match in matches: + if match.start() > last_end: + content += node_content[last_end:match.start()] + + cq_type = match.group(1) + cq_params_str = match.group(2) or "" + + params = {} + if cq_params_str: + for param in cq_params_str.split(','): + if '=' in param: + k, v = param.split('=', 1) + params[k] = v + + if cq_type in ("image", "video", "record"): + file_url = params.get("url") or params.get("file") + if file_url: + if str(file_url).startswith("http"): + content += f"\n{file_url}\n" + elif str(file_url).startswith("base64://"): + import base64 + import io + b64_data = str(file_url)[9:] + if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): + b64_data = b64_data.split(",", 1)[1] + try: + file_bytes = base64.b64decode(b64_data) + filename = "file.png" if cq_type == "image" else ("file.mp4" if cq_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + except Exception as e: + logger.error(f"解析 Base64 文件失败: {e}") + else: + try: + files.append(discord.File(file_url)) + except Exception as e: + logger.error(f"无法读取本地文件 {file_url}: {e}") + elif cq_type == "face": + # QQ 表情,简单转为文本 + face_id = params.get("id") + content += f"[表情:{face_id}]" + elif cq_type == "at": + qq_id = params.get("qq") + if qq_id == "all": + content += "@everyone " + else: + content += f"<@{qq_id}> " + + last_end = match.end() + + if last_end < len(node_content): + content += node_content[last_end:] + content += "\n" + elif isinstance(node_content, list): + for seg in node_content: + if isinstance(seg, dict): + seg_type = seg.get("type") + seg_data = seg.get("data", {}) + + if seg_type == "text": + content += seg_data.get("text", "") + elif seg_type in ("image", "video", "record"): + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + if isinstance(file_url, bytes): + import io + try: + filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_url), filename=filename)) + except Exception as e: + logger.error(f"解析 bytes 文件失败: {e}") + elif str(file_url).startswith("http"): + content += f"\n{file_url}\n" + elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): + import base64 + import io + b64_data = str(file_url) + if b64_data.startswith("base64://"): + b64_data = b64_data[9:] + if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): + b64_data = b64_data.split(",", 1)[1] + try: + file_bytes = base64.b64decode(b64_data) + filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + except Exception as e: + logger.error(f"解析 Base64 文件失败: {e}") + else: + try: + files.append(discord.File(file_url)) + except Exception as e: + logger.error(f"无法读取本地文件 {file_url}: {e}") + elif seg_type == "face": + face_id = seg_data.get("id") + content += f"[表情:{face_id}]" + content += "\n" + + try: + if content or files: + # target is usually event, we can use event.bot.send + if isinstance(target, GroupMessageEvent): + channel = self.adapter.get_channel(target.group_id) + if channel: + await channel.send(content=content, files=files if files else None) + elif isinstance(target, PrivateMessageEvent): + user = self.adapter.get_user(target.user_id) + if user: + if not user.dm_channel: + await user.create_dm() + await user.dm_channel.send(content=content, files=files if files else None) + except Exception as e: + logger.error(f"发送 Discord 合并转发消息失败: {e}") + +class DiscordToOneBotConverter: + """ + 将 Discord 消息转换为 OneBot 消息事件的转换器。 + """ + + @staticmethod + def create_mock_event(discord_message: 'discord.Message', adapter: Any) -> Union[GroupMessageEvent, PrivateMessageEvent]: + """ + 将 discord.Message 伪装成 OneBot 的 MessageEvent。 + + Args: + discord_message: 原始的 Discord 消息对象 + adapter: DiscordAdapter 实例,用于回调发送消息 + + Returns: + 伪装后的 OneBot 事件对象 + """ + # 1. 提取基础信息 + user_id = discord_message.author.id + message_id = discord_message.id + + # 处理 Discord 的 raw_message + # 如果消息是以 @机器人 开头,Discord 的 content 会是 "<@机器人ID> /echo 1" + # 我们需要把前面的 @ 提及去掉,否则命令匹配器 (matcher) 无法识别以 "/" 开头的命令 + raw_message = discord_message.content + + # 构造 message 列表 (将文本和附件转换为 MessageSegment) + message_list = [] + + # 添加文本内容 + if discord_message.content: + # 处理 Discord 自定义表情 <:name:id> 或 + import re + content = discord_message.content + + # 查找所有自定义表情 + emoji_pattern = r'' + + # 如果有表情,我们需要将文本分割成多个片段 + if re.search(emoji_pattern, content): + last_end = 0 + for match in re.finditer(emoji_pattern, content): + # 添加表情前的文本 + if match.start() > last_end: + text_part = content[last_end:match.start()] + if text_part: + message_list.append(OneBotMessageSegment.text(text_part)) + + # 添加表情作为图片 + emoji_name = match.group(1) + emoji_id = match.group(2) + is_animated = match.group(0).startswith('" + if raw_message.startswith(bot_mention): + raw_message = raw_message[len(bot_mention):].strip() + # 如果 message_list 的第一个元素是文本,也需要去掉 @ 提及 + if message_list and message_list[0].type == "text": + text_content = message_list[0].data.get("text", "") + if text_content.startswith(bot_mention): + message_list[0].data["text"] = text_content[len(bot_mention):].strip() + + # 构造发送者信息 + sender = Sender( + user_id=user_id, + nickname=discord_message.author.display_name, + card=getattr(discord_message.author, 'nick', ''), # 群名片 + role="member" # 简化处理,默认都是普通成员 + ) + + # 2. 判断是群聊还是私聊 + is_private = isinstance(discord_message.channel, discord.DMChannel) + + import time + current_time = int(time.time()) + self_id = adapter.user.id if adapter.user else 0 + + # 注入 Discord 特定信息(用于跨平台插件识别) + discord_channel_id = discord_message.channel.id if not isinstance(discord_message.channel, discord.DMChannel) else None + discord_username = discord_message.author.name + discord_discriminator = f"#{discord_message.author.discriminator}" if discord_message.author.discriminator != "0" else "" + + if is_private: + # 构造私聊事件 + event = PrivateMessageEvent( + time=current_time, + self_id=self_id, + platform="discord", + message_type="private", + sub_type="friend", + message_id=message_id, + user_id=user_id, + raw_message=raw_message, + message=message_list, + sender=sender + ) + else: + # 构造群聊事件 + group_id = discord_message.channel.id + event = GroupMessageEvent( + time=current_time, + self_id=self_id, + platform="discord", + message_type="group", + sub_type="normal", + message_id=message_id, + user_id=user_id, + group_id=group_id, + raw_message=raw_message, + message=message_list, + sender=sender + ) + + # 注入 Discord 特定属性(用于跨平台插件识别) + event._is_discord_message = True + event.discord_channel_id = discord_channel_id + event.discord_username = discord_username + event.discord_discriminator = discord_discriminator + + # 注入 DiscordBotWrapper + event.bot = DiscordBotWrapper(adapter) + + return event + + @staticmethod + async def send_discord_message( + channel: 'discord.abc.Messageable', + message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], + adapter: Any + ): + """ + 将 OneBot 的消息段转换为 Discord 格式并发送。 + + Args: + channel: Discord 频道对象 (TextChannel, DMChannel 等) + message: 插件返回的 OneBot 消息内容 (字符串或 MessageSegment 列表) + adapter: DiscordAdapter 实例 + """ + content = "" + files = [] + + # 统一转换为列表处理 + if not isinstance(message, list): + message = [message] + + import re + + for segment in message: + if isinstance(segment, str): + # 尝试解析 CQ 码 + cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' + matches = list(re.finditer(cq_pattern, segment)) + + if not matches: + content += segment + continue + + last_end = 0 + for match in matches: + # 添加 CQ 码之前的纯文本 + if match.start() > last_end: + content += segment[last_end:match.start()] + + cq_type = match.group(1) + cq_params_str = match.group(2) or "" + + # 解析参数 + params = {} + if cq_params_str: + for param in cq_params_str.split(','): + if '=' in param: + k, v = param.split('=', 1) + params[k] = v + + if cq_type in ("image", "video", "record"): + file_url = params.get("url") or params.get("file") + if file_url: + if str(file_url).startswith("http"): + content += f"\n{file_url}" + elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): + import base64 + import io + b64_data = str(file_url) + if b64_data.startswith("base64://"): + b64_data = b64_data[9:] + if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): + b64_data = b64_data.split(",", 1)[1] + try: + file_bytes = base64.b64decode(b64_data) + filename = "file.png" if cq_type == "image" else ("file.mp4" if cq_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + except Exception as e: + logger.error(f"解析 Base64 文件失败: {e}") + else: + try: + files.append(discord.File(file_url)) + except Exception as e: + logger.error(f"无法读取本地文件 {file_url}: {e}") + elif cq_type == "face": + face_id = params.get("id") + content += f"[表情:{face_id}]" + elif cq_type == "at": + qq_id = params.get("qq") + if qq_id == "all": + content += "@everyone " + else: + content += f"<@{qq_id}> " + + last_end = match.end() + + # 添加最后一个 CQ 码之后的纯文本 + if last_end < len(segment): + content += segment[last_end:] + + elif isinstance(segment, OneBotMessageSegment): + # 解析 OneBot 的 MessageSegment + seg_type = segment.type + seg_data = segment.data + + if seg_type == "text": + content += seg_data.get("text", "") + elif seg_type in ("image", "video", "record"): + # OneBot 的图片/视频/语音通常有 file (URL或本地路径) 或 url 字段 + file_url = seg_data.get("url") or seg_data.get("file") + + if file_url: + # 处理 bytes 类型 + if isinstance(file_url, bytes): + import io + try: + filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_url), filename=filename)) + except Exception as e: + logger.error(f"解析 bytes 文件失败: {e}") + elif str(file_url).startswith("http"): + # 如果是网络 URL,直接拼接到文本中,Discord 会自动解析预览 + content += f"\n{file_url}" + elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): + # 处理 Base64 文件 (需要解码并作为文件上传) + import base64 + import io + b64_data = str(file_url) + if b64_data.startswith("base64://"): + b64_data = b64_data[9:] + if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): + b64_data = b64_data.split(",", 1)[1] + try: + file_bytes = base64.b64decode(b64_data) + filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + except Exception as e: + logger.error(f"解析 Base64 文件失败: {e}") + else: + # 假设是本地文件路径 + try: + files.append(discord.File(file_url)) + except Exception as e: + logger.error(f"无法读取本地文件 {file_url}: {e}") + elif seg_type == "face": + face_id = seg_data.get("id") + content += f"[表情:{face_id}]" + elif seg_type == "at": + qq_id = seg_data.get("qq") + if qq_id == "all": + content += "@everyone " + else: + # 尝试将 QQ 号映射回 Discord ID (这里简单处理,直接拼接) + content += f"<@{qq_id}> " + elif seg_type == "reply": + # 忽略回复段,或者你可以尝试映射 message_id + pass + + # 发送消息到 Discord + try: + # 如果内容为空但有文件,Discord 允许发送 + if content or files: + await channel.send(content=content, files=files if files else None) + else: + logger.warning("尝试发送空消息到 Discord,已拦截") + except Exception as e: + logger.error(f"发送 Discord 消息失败: {e}") diff --git a/plugins/discord-cross/handlers.py b/plugins/discord-cross/handlers.py new file mode 100644 index 0000000..a8a9cf0 --- /dev/null +++ b/plugins/discord-cross/handlers.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件事件处理器模块 +""" +import os +import html +from typing import List, Any +from core.managers.command_manager import matcher +from models.events.message import GroupMessageEvent, MessageEvent +from models.message import MessageSegment +from core.permission import Permission +from core.utils.logger import logger +from .config import config +from .parser import parse_forward_nodes +from .sender import forward_discord_to_qq, forward_qq_to_discord + +async def handle_discord_message( + username: str, + discriminator: str, + content: str, + channel_id: int, + attachments: List[dict] = None, + embed: dict = None +): + """处理 Discord 消息并转发""" + if not config.ENABLE_CROSS_PLATFORM: + return + + logger.info(f"[CrossPlatform] 收到 Discord 消息: {username}#{discriminator} in {channel_id}") + logger.debug(f"[CrossPlatform] 消息内容: '{content}', 附件: {attachments}") + await forward_discord_to_qq(username, discriminator, content, channel_id, attachments) + +async def handle_qq_message( + nickname: str, + user_id: int, + group_name: str, + group_id: int, + content: str, + attachments: List[dict] = None +): + """处理 QQ 消息并转发""" + if not config.ENABLE_CROSS_PLATFORM: + return + + logger.info(f"[CrossPlatform] 收到 QQ 消息: {nickname} ({user_id}) in {group_name}({group_id})") + await forward_qq_to_discord(nickname, user_id, group_name, group_id, content, attachments) + +@matcher.on_message() +async def handle_qq_group_message(event: GroupMessageEvent): + """处理 QQ 群消息,转发到 Discord""" + if not config.ENABLE_CROSS_PLATFORM: + return + + group_id = event.group_id + mapped_channel = None + for discord_channel_id, info in config.CROSS_PLATFORM_MAP.items(): + if info["qq_group_id"] == group_id: + mapped_channel = discord_channel_id + break + + if mapped_channel is None: + return + + content = "" + attachments = [] + + if isinstance(event.message, list): + has_forward_node = any(isinstance(seg, MessageSegment) and seg.type == "node" for seg in event.message) + + if has_forward_node: + forward_nodes = [seg for seg in event.message if isinstance(seg, MessageSegment) and seg.type == "node"] + forward_nodes_dict = [{"type": seg.type, "data": seg.data} for seg in forward_nodes] + content, attachments = await parse_forward_nodes(forward_nodes_dict) + else: + for segment in event.message: + if isinstance(segment, MessageSegment): + if segment.type == "text": + content += segment.data.get("text", "") + elif segment.type == "image": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_url = html.unescape(str(file_url)) + if not file_name: + file_name = os.path.basename(file_url.split('?')[0]) or f"image_{len(attachments)}.jpg" + attachments.append({"type": "image", "url": file_url, "filename": file_name}) + elif segment.type == "video": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_url = html.unescape(str(file_url)) + if not file_name: + file_name = os.path.basename(file_url.split('?')[0]) or f"video_{len(attachments)}.mp4" + attachments.append({"type": "video", "url": file_url, "filename": file_name}) + elif segment.type == "record": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_url = html.unescape(str(file_url)) + if not file_name: + file_name = os.path.basename(file_url.split('?')[0]) or f"record_{len(attachments)}.amr" + attachments.append({"type": "record", "url": file_url, "filename": file_name}) + content += f"\n[语音: {file_name}]\n" + elif segment.type == "file": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_url = html.unescape(str(file_url)) + if not file_name: + file_name = os.path.basename(file_url.split('?')[0]) or f"file_{len(attachments)}" + attachments.append({"type": "file", "url": file_url, "filename": file_name}) + content += f"\n[文件: {file_name}]\n" + logger.debug(f"[CrossPlatform] QQ 消息识别到文件: {file_name}, URL: {file_url}") + elif segment.type == "at": + qq_id = segment.data.get("qq") + if qq_id and qq_id != "all": + content += f"@{qq_id} " + elif qq_id == "all": + content += "@所有人 " + elif isinstance(segment, str): + content += segment + elif isinstance(event.message, str): + content = event.message + + import re + local_file_pattern = r'(http://[\w\.-]+:\d+/download\?id=file_[a-zA-Z0-9_]+)' + matches = re.finditer(local_file_pattern, content) + for match in matches: + file_url = match.group(1) + file_name = f"video_{len(attachments)}.mp4" + attachments.append({"type": "video", "url": file_url, "filename": file_name}) + + content = content.strip() + + group_name = "" + try: + group_info = await event.bot.get_group_info(event.group_id) + group_name = group_info.get("group_name", "") + except Exception: + group_name = f"群{group_id}" + + await handle_qq_message( + nickname=event.sender.nickname or event.sender.card or str(event.user_id), + user_id=event.user_id, + group_name=group_name, + group_id=group_id, + content=content, + attachments=attachments + ) + +@matcher.on_message() +async def handle_discord_message_event(event: Any): + """处理 Discord 消息事件(通过适配器注入)""" + if not config.ENABLE_CROSS_PLATFORM: + return + + logger.debug(f"[CrossPlatform] handle_discord_message_event 触发: {event}") + if not hasattr(event, '_is_discord_message'): + logger.debug(f"[CrossPlatform] 事件没有 _is_discord_message 属性,跳过") + return + + logger.debug(f"[CrossPlatform] 检测到 Discord 事件") + discord_channel_id = getattr(event, 'discord_channel_id', None) + if discord_channel_id is None: + logger.debug(f"[CrossPlatform] discord_channel_id 为 None") + return + + content = "" + attachments = [] + + logger.debug(f"[CrossPlatform] 开始处理 Discord 事件消息: channel_id={discord_channel_id}") + + if hasattr(event, 'message') and isinstance(event.message, list): + for segment in event.message: + if isinstance(segment, MessageSegment): + if segment.type == "text": + content += segment.data.get("text", "") + elif segment.type == "image": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "image" + attachment_item = {"type": "image", "url": str(file_url), "filename": file_name} + if attachment_item not in attachments: + attachments.append(attachment_item) + elif segment.type == "video": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video" + attachment_item = {"type": "video", "url": str(file_url), "filename": file_name} + if attachment_item not in attachments: + attachments.append(attachment_item) + elif segment.type == "record": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "record" + attachment_item = {"type": "record", "url": str(file_url), "filename": file_name} + if attachment_item not in attachments: + attachments.append(attachment_item) + elif segment.type == "file": + file_url = segment.data.get("url") or segment.data.get("file") + file_name = segment.data.get("filename") + if file_url: + file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "file" + attachment_item = {"type": "file", "url": str(file_url), "filename": file_name} + if attachment_item not in attachments: + attachments.append(attachment_item) + logger.debug(f"[CrossPlatform] Discord 消息识别到文件: {file_name}, URL: {file_url}") + else: + content = event.raw_message or "" + + content = content.strip() + + logger.debug(f"[CrossPlatform] Discord 消息内容: '{content}', 附件数量: {len(attachments)}") + + discord_username = getattr(event, 'discord_username', 'Unknown') + discord_discriminator = getattr(event, 'discord_discriminator', '') + + logger.debug(f"[CrossPlatform] 调用 handle_discord_message: username={discord_username}, channel_id={discord_channel_id}") + await handle_discord_message( + username=discord_username, + discriminator=discord_discriminator, + content=content, + channel_id=discord_channel_id, + attachments=attachments, + embed=None + ) + +@matcher.command("cross_config", "跨平台配置", permission=Permission.ADMIN) +async def cross_config_command(event: MessageEvent): + """查看跨平台配置""" + if not config.ENABLE_CROSS_PLATFORM: + await event.reply("跨平台功能已禁用") + return + + config_lines = ["=== 跨平台映射配置 ==="] + + if not config.CROSS_PLATFORM_MAP: + config_lines.append("当前没有配置任何映射") + else: + for discord_id, info in config.CROSS_PLATFORM_MAP.items(): + discord_channel = f"Discord: {discord_id}" + qq_group = f"QQ: {info['qq_group_id']}" + name = info.get("name", "") + if name: + config_lines.append(f"• {discord_channel} ↔ {qq_group} ({name})") + else: + config_lines.append(f"• {discord_channel} ↔ {qq_group}") + + await event.reply("\n".join(config_lines)) + +@matcher.command("cross_reload", "跨平台重载", permission=Permission.ADMIN) +async def cross_reload_command(event: MessageEvent): + """重新加载跨平台配置""" + await config.reload() + await event.reply("跨平台配置已重载") \ No newline at end of file diff --git a/src/neobot/adapters/discord_adapter.py b/src/neobot/adapters/discord_adapter.py index d941342..b9565e5 100644 --- a/src/neobot/adapters/discord_adapter.py +++ b/src/neobot/adapters/discord_adapter.py @@ -21,10 +21,10 @@ try: except ImportError: DISCORD_AVAILABLE = False -from neobot.core.utils.logger import ModuleLogger +from core.utils.logger import ModuleLogger from .router import DiscordToOneBotConverter -from neobot.core.managers.redis_manager import redis_manager -from neobot.core.config_loader import global_config +from core.managers.redis_manager import redis_manager +from core.config_loader import global_config class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): """ @@ -41,7 +41,6 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): self.proxy = None self.proxy_type = "http" - self._redis_sub_task = None if global_config.discord.proxy: self.proxy = global_config.discord.proxy self.proxy_type = global_config.discord.proxy_type or "http" @@ -66,29 +65,8 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): self.start_heartbeat_task(interval=30) - if self._redis_sub_task is None or self._redis_sub_task.done(): - if self._redis_sub_task is not None and not self._redis_sub_task.done(): - self._redis_sub_task.cancel() - try: - await self._redis_sub_task - except asyncio.CancelledError: - pass - self._redis_sub_task = asyncio.create_task(self.start_redis_subscription()) - - async def on_resumed(self): - """当 Bot 重新连接到 Discord 时触发""" - self.logger.success(f"Discord Bot 已重新连接: {self.user} (ID: {self.user.id})") - - self.start_heartbeat_task(interval=30) - - if self._redis_sub_task is None or self._redis_sub_task.done(): - if self._redis_sub_task is not None and not self._redis_sub_task.done(): - self._redis_sub_task.cancel() - try: - await self._redis_sub_task - except asyncio.CancelledError: - pass - self._redis_sub_task = asyncio.create_task(self.start_redis_subscription()) + # 启动 Redis 订阅以处理跨平台消息 + asyncio.create_task(self.start_redis_subscription()) async def on_message(self, message: 'discord.Message'): """当收到 Discord 消息时触发""" @@ -101,7 +79,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): # 1. 将 discord.Message 伪装成 OneBot 事件模型 # 2. 触发业务逻辑 # 将伪装后的事件丢给现有的命令管理器 (matcher) - from neobot.core.managers.command_manager import matcher + from core.managers.command_manager import matcher # matcher.handle_event 需要 bot 实例和 event 实例 # 我们在 create_mock_event 中已经注入了一个假的 bot 对象 @@ -110,13 +88,10 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): await matcher.handle_event(mock_event.bot, mock_event) except Exception as e: self.logger.error(f"处理 Discord 消息时发生异常: {e}") - # 记录详细的异常信息 - import traceback - self.logger.error(f"异常堆栈: {traceback.format_exc()}") async def start_redis_subscription(self): """启动 Redis 订阅以处理跨平台消息发送""" - if redis_manager._redis is None: + if redis_manager.redis is None: self.logger.warning("[DiscordAdapter] Redis 未初始化,跳过订阅") return @@ -132,8 +107,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): try: data = json.loads(message["data"]) if data.get("type") == "send_message": - # 使用 asyncio.create_task 异步处理消息,避免阻塞订阅循环 - asyncio.create_task(self.handle_send_message(data)) + await self.handle_send_message(data) except json.JSONDecodeError as e: self.logger.error(f"[DiscordAdapter] 解析 Redis 消息失败: {e}") except Exception as e: @@ -214,12 +188,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): self.logger.error(f"[DiscordAdapter] 未找到频道: {channel_id}") return - # 检查会话状态 - if not self.is_closed(): - self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}") - else: - self.logger.warning(f"[DiscordAdapter] 会话已关闭,消息将被丢弃: channel_id={channel_id}") - return + self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}") embed = None if embed_data: @@ -244,12 +213,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): if attachment_url.startswith('http'): try: - import aiohttp - proxy_url = self.proxy if self.proxy else None - async with aiohttp.ClientSession() as session: - async with session.get(attachment_url, proxy=proxy_url, timeout=30) as response: - content_bytes = await response.read() - + response = requests.get(attachment_url, proxies=proxies, timeout=30) if not filename: filename = os.path.basename(attachment_url.split('?')[0]) or "attachment" @@ -258,7 +222,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): if is_voice: # 尝试转换为 OGG Opus - ogg_bytes = await self.convert_to_ogg_opus(content_bytes) + ogg_bytes = await self.convert_to_ogg_opus(response.content) if ogg_bytes: # 转换成功,作为语音消息发送 # discord.py 官方 API 目前不支持直接发送语音消息 @@ -300,21 +264,16 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): files.append(discord.File(fp=io.BytesIO(ogg_bytes), filename="voice.ogg")) else: # 转换失败,作为普通文件发送 - files.append(discord.File(fp=io.BytesIO(content_bytes), filename=filename)) + files.append(discord.File(fp=io.BytesIO(response.content), filename=filename)) else: - files.append(discord.File(fp=io.BytesIO(content_bytes), filename=filename)) + files.append(discord.File(fp=io.BytesIO(response.content), filename=filename)) except Exception as e: self.logger.error(f"[DiscordAdapter] 下载附件失败: {attachment_url}, 错误: {e}") if content or files or embed: - try: - await channel.send(content=content, files=files if files else None, embed=embed) - self.logger.success(f"[DiscordAdapter] 消息已发送到频道 {channel_id}") - except Exception as send_error: - self.logger.error(f"[DiscordAdapter] 发送消息失败 (channel.send): {send_error}") - raise - else: - self.logger.debug(f"[DiscordAdapter] 没有内容需要发送到频道 {channel_id}") + await channel.send(content=content, files=files if files else None, embed=embed) + + self.logger.success(f"[DiscordAdapter] 消息已发送到频道 {channel_id}") except Exception as e: self.logger.error(f"[DiscordAdapter] 发送消息失败: {e}") @@ -340,64 +299,21 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): except asyncio.CancelledError: self.logger.info("连接被取消") break - except discord.ConnectionClosed as e: - retry_count += 1 - self.logger.warning(f"Discord 连接关闭: code={e.code}, reason={e.reason}") - - # 如果是正常关闭,不计入重连次数 - if e.code == 1000: - self.logger.info("连接正常关闭,等待重新连接...") - continue - - if max_retries != -1 and retry_count >= max_retries: - self.logger.error(f"已达到最大重连次数 ({max_retries}),停止重连") - break - - self.logger.info(f"将在 {retry_delay} 秒后重连 ({retry_count}/{max_retries if max_retries != -1 else '无限'})...") - await self._cleanup_connection() - await asyncio.sleep(retry_delay) except Exception as e: retry_count += 1 - self.logger.error(f"Discord 连接异常: {e}") + self.logger.error(f"Discord 连接失败: {e}") if max_retries != -1 and retry_count >= max_retries: self.logger.error(f"已达到最大重连次数 ({max_retries}),停止重连") break self.logger.info(f"将在 {retry_delay} 秒后重连 ({retry_count}/{max_retries if max_retries != -1 else '无限'})...") - await self._cleanup_connection() + # 清理旧的连接状态 + self.clear() await asyncio.sleep(retry_delay) self.logger.info("Discord 客户端已停止") - async def _cleanup_connection(self): - """ - 清理旧的连接状态 - """ - try: - # 停止心跳任务 - if hasattr(self, 'heartbeat_task') and not self.heartbeat_task.done(): - self.heartbeat_task.cancel() - try: - await self.heartbeat_task - except asyncio.CancelledError: - pass - except Exception as e: - self.logger.error(f"清理心跳任务时出错: {e}") - - try: - # 清理 HTTP 连接 - if hasattr(self, 'http') and self.http: - await self.http.close() - except Exception as e: - self.logger.error(f"清理 HTTP 连接时出错: {e}") - - try: - # 清理客户端状态 - self.clear() - except Exception as e: - self.logger.error(f"清理客户端状态时出错: {e}") - async def start_heartbeat(self, interval: int = 30): """ 启动心跳机制,定期检查连接状态 @@ -407,20 +323,16 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): """ self.logger.info(f"心跳机制已启动,间隔: {interval}秒") - while not self.is_closed(): + while self.is_closed() is False: try: await asyncio.sleep(interval) - # 检查 WebSocket 连接状态 - if self.ws is not None: - # 正确检查 WebSocket 状态 - if not getattr(self.ws, 'open', False): - self.logger.warning("检测到 WebSocket 连接已关闭,触发重连...") - try: - await self.ws.close(code=4000) - except Exception as close_error: - self.logger.error(f"关闭 WebSocket 连接时出错: {close_error}") - break + # discord.py 的 ws 对象是 DiscordWebSocket,它没有 closed 属性 + # 我们可以通过检查 self.is_closed() 或者 ws.open 来判断 + if self.ws is not None and not getattr(self.ws, 'open', True): + self.logger.warning("检测到 WebSocket 连接已关闭,触发重连...") + await self.close() + break self.logger.debug(f"心跳正常: {self.user}")