From f8685533428e87a5aa6d544f6bc1f8c3a3a886b0 Mon Sep 17 00:00:00 2001 From: K2Cr2O1 <2221577113@qq.com> Date: Sun, 15 Mar 2026 13:36:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Discord=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E4=B8=8E=E8=B7=A8=E5=B9=B3=E5=8F=B0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=BA=92=E9=80=9A=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Discord适配器支持,实现Discord与QQ之间的消息互通 添加通用数据模型用于跨平台消息转换 扩展配置系统以支持Discord和日志配置 重构日志系统以使用配置中的日志级别 在反向WebSocket管理器中注册Bot实例 更新主程序以支持Discord客户端启动 添加测试脚本验证核心功能 --- adapters/discord_adapter.py | 136 ++++++ adapters/router.py | 406 ++++++++++++++++ adapters/universal_model.py | 101 ++++ config.toml | 41 +- core/config_loader.py | 15 +- core/config_models.py | 19 + core/managers/reverse_ws_manager.py | 7 +- core/utils/logger.py | 24 +- core/ws.py | 2 +- main.py | 13 + plugins/cross_platform.py | 703 ++++++++++++++++++++++++++++ test_image_fix.py | 36 ++ 12 files changed, 1490 insertions(+), 13 deletions(-) create mode 100644 adapters/discord_adapter.py create mode 100644 adapters/router.py create mode 100644 adapters/universal_model.py create mode 100644 plugins/cross_platform.py create mode 100644 test_image_fix.py diff --git a/adapters/discord_adapter.py b/adapters/discord_adapter.py new file mode 100644 index 0000000..a0f90d8 --- /dev/null +++ b/adapters/discord_adapter.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +Discord 适配器 (Discord Adapter) + +此模块负责与 Discord API 建立连接,接收 Discord 消息, +并将其转换为通用数据模型 (Universal Data Models), +同时提供将通用消息段发送回 Discord 的能力。 +""" +import asyncio +import json +import os +import io +import requests +from typing import Union, List, Optional + +try: + import discord + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + +from core.utils.logger import ModuleLogger +from .router import DiscordToOneBotConverter +from core.managers.redis_manager import redis_manager + +class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): + """ + Discord 客户端适配器。 + 继承自 discord.Client,负责处理 Discord 的底层事件。 + """ + def __init__(self, token: str): + if not DISCORD_AVAILABLE: + raise ImportError("discord.py 未安装,请运行 `pip install discord.py`") + + # 必须声明 Intents,否则无法读取消息内容 + intents = discord.Intents.default() + intents.message_content = True + + super().__init__(intents=intents) + self.token = token + self.logger = ModuleLogger("DiscordAdapter") + self.send_channel = None + + async def on_ready(self): + """当 Bot 成功连接到 Discord 时触发""" + self.logger.success(f"Discord Bot 已登录: {self.user} (ID: {self.user.id})") + + # 启动 Redis 订阅以处理跨平台消息 + asyncio.create_task(self.start_redis_subscription()) + + async def on_message(self, message: 'discord.Message'): + """当收到 Discord 消息时触发""" + # 忽略机器人自己的消息 + if message.author.bot: + return + + self.logger.info(f"[Discord 消息] {message.author}: {message.content}") + + # 1. 将 discord.Message 伪装成 OneBot 事件模型 + # 2. 触发业务逻辑 + # 将伪装后的事件丢给现有的命令管理器 (matcher) + from core.managers.command_manager import matcher + + # matcher.handle_event 需要 bot 实例和 event 实例 + # 我们在 create_mock_event 中已经注入了一个假的 bot 对象 + try: + mock_event = DiscordToOneBotConverter.create_mock_event(message, self) + await matcher.handle_event(mock_event.bot, mock_event) + except Exception as e: + self.logger.error(f"处理 Discord 消息时发生异常: {e}") + + async def start_redis_subscription(self): + """启动 Redis 订阅以处理跨平台消息发送""" + if redis_manager.redis is None: + self.logger.warning("[DiscordAdapter] Redis 未初始化,跳过订阅") + return + + try: + channel_name = "neobot_discord_send" + pubsub = redis_manager.redis.pubsub() + await pubsub.subscribe(channel_name) + + self.logger.success(f"[DiscordAdapter] 已订阅 Redis 频道: {channel_name}") + + async for message in pubsub.listen(): + if message["type"] == "message": + try: + data = json.loads(message["data"]) + if data.get("type") == "send_message": + await self.handle_send_message(data) + except json.JSONDecodeError as e: + self.logger.error(f"[DiscordAdapter] 解析 Redis 消息失败: {e}") + except Exception as e: + self.logger.error(f"[DiscordAdapter] 处理 Redis 消息失败: {e}") + + except Exception as e: + self.logger.error(f"[DiscordAdapter] Redis 订阅异常: {e}") + + async def handle_send_message(self, data: dict): + """处理来自 Redis 的消息发送请求""" + try: + channel_id = data.get("channel_id") + content = data.get("content", "") + attachments = data.get("attachments", []) + + if channel_id is None: + self.logger.error("[DiscordAdapter] 缺少 channel_id") + return + + channel = self.get_channel(channel_id) + if channel is None: + self.logger.error(f"[DiscordAdapter] 未找到频道: {channel_id}") + return + + self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}") + + # 发送内容和附件(合并为一条消息) + if content or attachments: + await channel.send(content=content, files=[discord.File(fp=io.BytesIO(requests.get(attachment_url).content), filename=os.path.basename(attachment_url)) for attachment_url in attachments if attachment_url.startswith('http')] if attachments else None) + + self.logger.success(f"[DiscordAdapter] 消息已发送到频道 {channel_id}") + + except Exception as e: + self.logger.error(f"[DiscordAdapter] 发送消息失败: {e}") + + async def start_client(self): + """启动 Discord 客户端(非阻塞方式)""" + if not DISCORD_AVAILABLE: + self.logger.error("无法启动 Discord 客户端:discord.py 未安装") + return + + try: + self.logger.info("正在连接 Discord...") + await self.start(self.token) + except Exception as e: + self.logger.error(f"Discord 连接失败: {e}") diff --git a/adapters/router.py b/adapters/router.py new file mode 100644 index 0000000..074ab58 --- /dev/null +++ b/adapters/router.py @@ -0,0 +1,406 @@ +# -*- 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 + +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 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 + + # 添加附件信息到 raw_message + if discord_message.attachments: + for attachment in discord_message.attachments: + raw_message += f"\n{attachment.url}" + bot_mention = f"<@{adapter.user.id}>" + if raw_message.startswith(bot_mention): + raw_message = raw_message[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) + + # 构造 message 列表 (将纯文本转换为 MessageSegment) + message_list = [OneBotMessageSegment.text(raw_message)] + + 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, + 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, + 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 + + # 3. 拦截并重写 reply 方法 (核心魔法) + # 插件调用 event.reply() 时,实际上会执行这个闭包 + async def mock_reply(message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False): + await DiscordToOneBotConverter.send_discord_reply(discord_message, message, adapter) + + # 覆盖实例方法 + event.reply = mock_reply + + # 注入一个假的 bot 对象,防止插件调用 event.bot.xxx 时报错 + # 这里只提供最基础的属性,如果插件调用了复杂的 API,可能会报错 + class MockBot: + def __init__(self): + self.self_id = adapter.user.id if adapter.user else 0 + + async def send(self, event, message, **kwargs): + await DiscordToOneBotConverter.send_discord_reply(discord_message, message, adapter) + + 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): + # 尝试解析 CQ 码 + 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"): + 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"): + b64_data = b64_data.split(",", 1)[1] + try: + image_bytes = base64.b64decode(b64_data) + files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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 == "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 == "image" or seg_type == "video": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + if isinstance(file_url, bytes): + import io + try: + files.append(discord.File(fp=io.BytesIO(file_url), filename="image.png")) + 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): + 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"): + b64_data = b64_data.split(",", 1)[1] + try: + image_bytes = base64.b64decode(b64_data) + files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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}") + content += "\n" + + try: + if content or files: + await discord_message.channel.send(content=content, files=files if files else None) + except Exception as e: + logger.error(f"发送 Discord 合并转发消息失败: {e}") + + event.bot = MockBot() + + return event + + @staticmethod + async def send_discord_reply( + original_message: 'discord.Message', + message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], + adapter: Any + ): + """ + 将 OneBot 的消息段转换为 Discord 格式并发送。 + + Args: + original_message: 触发此回复的原始 Discord 消息 + 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"): + 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): + 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"): + b64_data = b64_data.split(",", 1)[1] + try: + image_bytes = base64.b64decode(b64_data) + files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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 == "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 == "image" or seg_type == "video": + # 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: + files.append(discord.File(fp=io.BytesIO(file_url), filename="image.png")) + 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): + # 处理 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"): + b64_data = b64_data.split(",", 1)[1] + try: + image_bytes = base64.b64decode(b64_data) + files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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 == "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 original_message.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/adapters/universal_model.py b/adapters/universal_model.py new file mode 100644 index 0000000..7afc5e3 --- /dev/null +++ b/adapters/universal_model.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +""" +通用数据模型 (Universal Data Models) + +此模块定义了平台无关的数据结构,用于在不同平台(如 OneBot, Discord) +和业务逻辑层(如 Plugins)之间传递数据。 +""" +from dataclasses import dataclass, field +from typing import List, Optional, Union, Dict, Any + +@dataclass +class UniversalMessageSegment: + """ + 平台无关的通用消息段模型。 + 业务逻辑层只负责生成这个对象,由底层的 Adapter 负责将其翻译成特定平台的格式。 + """ + type: str # 消息类型:'text', 'image', 'video', 'audio', 'at', 'reply' 等 + data: Dict[str, Any] # 消息数据载荷 + + @staticmethod + def text(text: str) -> "UniversalMessageSegment": + return UniversalMessageSegment("text", {"text": text}) + + @staticmethod + def image(url: Optional[str] = None, base64: Optional[str] = None, file_path: Optional[str] = None) -> "UniversalMessageSegment": + """ + 图片消息。 + Discord 支持直接发 URL 或上传本地文件;OneBot 支持 URL、Base64 或本地路径。 + """ + return UniversalMessageSegment("image", {"url": url, "base64": base64, "file_path": file_path}) + + @staticmethod + def video(url: Optional[str] = None, file_path: Optional[str] = None) -> "UniversalMessageSegment": + """ + 视频消息。 + Discord 通常直接发 URL 或作为附件上传;OneBot 支持 URL 或本地路径。 + """ + return UniversalMessageSegment("video", {"url": url, "file_path": file_path}) + + @staticmethod + def at(user_id: str) -> "UniversalMessageSegment": + """ + @某人。 + 注意:为了兼容 Discord 的雪花 ID (Snowflake),user_id 必须是字符串。 + """ + return UniversalMessageSegment("at", {"user_id": user_id}) + + @staticmethod + def reply(message_id: str) -> "UniversalMessageSegment": + """ + 回复某条消息。 + """ + return UniversalMessageSegment("reply", {"message_id": message_id}) + +@dataclass +class UniversalUser: + """通用用户模型""" + id: str # 用户唯一ID (QQ号 或 Discord Snowflake ID) + name: str # 用户昵称/群名片 + avatar_url: str # 头像URL + is_bot: bool # 是否是机器人 + +@dataclass +class UniversalChannel: + """通用频道/群组模型""" + id: str # 频道/群组唯一ID (QQ群号 或 Discord Channel ID) + name: str # 频道/群组名称 + type: str # 类型:'private' (私聊), 'group' (QQ群), 'guild_text' (Discord文字频道) 等 + guild_id: Optional[str] = None # 仅 Discord 有效:服务器(Guild) ID + +@dataclass +class UniversalMessageEvent: + """ + 平台无关的通用消息事件模型。 + 这是传递给业务逻辑层(如 bili.py)的最终对象。 + """ + platform: str # 来源平台标识:'onebot' 或 'discord' + + message_id: str # 消息唯一ID (QQ消息ID 或 Discord Message ID) + + user: UniversalUser # 发送者信息 + channel: UniversalChannel # 消息来源频道/群组信息 + + raw_message: str # 纯文本形式的消息内容(用于正则匹配、命令解析) + + # 解析后的消息段列表(可选,如果你需要处理图文混排) + message: List[UniversalMessageSegment] = field(default_factory=list) + + # 原始的底层事件对象(保留引用,方便高级操作) + # 例如:OneBot 的原始 JSON 字典,或 discord.py 的 discord.Message 对象 + raw_event: Any = field(repr=False, default=None) + + async def reply(self, message: Union[str, UniversalMessageSegment, List[UniversalMessageSegment]]): + """ + 统一的回复接口。 + 这个方法应该是一个抽象方法或由具体的 Adapter 注入实现。 + 业务逻辑层调用此方法时,不需要关心底层是调用 OneBot API 还是 Discord API。 + """ + raise NotImplementedError("此方法应由具体的 Platform Adapter 实现") + + diff --git a/config.toml b/config.toml index 801c159..73d5abd 100644 --- a/config.toml +++ b/config.toml @@ -3,9 +3,9 @@ # NapCat WebSocket 配置 [napcat_ws] -uri = "ws://127.0.0.1:3001" +uri = "ws://127.0.0.1:6700" # WebSocket 连接地址 -token = "KoIAF.mcEHzxrPYF" +token = "" # 重连间隔(秒) reconnect_interval = 5 @@ -13,8 +13,8 @@ reconnect_interval = 5 [reverse_ws] enabled = true # 是否启用 host = "0.0.0.0" # 监听地址 -port = 3002 # 监听端口 -token = "" +port = 8095 # 监听端口 +token = "U~jqzl-F8oUXtle-" # Bot 基础配置 [bot] @@ -96,5 +96,36 @@ dedeuserid = "" # 用于下载远程文件到本地并提供本地访问,解决 NapCat 无法直接访问某些远程资源的问题 [local_file_server] enabled = true # 是否启用 -host = "101.36.126.55" # 监听地址 +host = "0.0.0.0" # 监听地址,0.0.0.0 表示监听所有网卡 port = 3003 # 监听端口 +base_url = "http://101.36.126.55:3003" # 外部访问的 URL + +[discord] +enabled = true +token = "MTQ4MjQzODA1NzExNzYxODI4Nw.G9R6uR.ddxHn3pmUf7SyrrOBg_-_lc7Y62lsCitPxpdGM" + +# 跨平台消息互通配置 +[cross_platform] +enabled = true # 是否启用跨平台互通 +# 映射配置 +# 格式: discord频道ID = {qq_group_id = QQ群ID, name = "显示名称"} +# 示例: +# [cross_platform.mappings.123456789012345678] +# qq_group_id = 123456789 +# name = "主群" +# [cross_platform.mappings.987654321098765432] +# qq_group_id = 987654321 +# name = "测试群" + +[cross_platform.mappings.1482413235474006067] +qq_group_id = 542898825 +name = "Paw" + +# 日志配置 +[logging] +# 控制台日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR) +console_level = "INFO" +# 文件日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR) +file_level = "DEBUG" +# 全局日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR) +level = "DEBUG" diff --git a/core/config_loader.py b/core/config_loader.py index 9b4d9d0..1fdd706 100644 --- a/core/config_loader.py +++ b/core/config_loader.py @@ -7,7 +7,7 @@ from pathlib import Path import tomllib from pydantic import ValidationError -from .config_models import ConfigModel, NapCatWSModel, BotModel, RedisModel, DockerModel, ImageManagerModel, MySQLModel, ReverseWSModel, ThreadingModel, BilibiliModel, LocalFileServerModel +from .config_models import ConfigModel, NapCatWSModel, BotModel, RedisModel, DockerModel, ImageManagerModel, MySQLModel, ReverseWSModel, ThreadingModel, BilibiliModel, LocalFileServerModel, DiscordModel, LoggingModel from .utils.logger import ModuleLogger from .utils.exceptions import ConfigError, ConfigNotFoundError, ConfigValidationError @@ -156,7 +156,20 @@ class Config: 获取本地文件服务器配置 """ return self._model.local_file_server + + @property + def discord(self) -> DiscordModel: + """ + 获取 Discord 配置 + """ + return self._model.discord + @property + def logging(self) -> LoggingModel: + """ + 获取日志配置 + """ + return self._model.logging # 实例化全局配置对象 diff --git a/core/config_models.py b/core/config_models.py index 817d326..c3fe950 100644 --- a/core/config_models.py +++ b/core/config_models.py @@ -107,6 +107,23 @@ class LocalFileServerModel(BaseModel): port: int = 3003 +class DiscordModel(BaseModel): + """ + 对应 `config.toml` 中的 `[discord]` 配置块。 + """ + enabled: bool = False + token: str = "" + + +class LoggingModel(BaseModel): + """ + 对应 `config.toml` 中的 `[logging]` 配置块。 + """ + level: str = "DEBUG" + file_level: str = "DEBUG" + console_level: str = "INFO" + + class ConfigModel(BaseModel): """ 顶层配置模型,整合了所有子配置块。 @@ -121,5 +138,7 @@ class ConfigModel(BaseModel): threading: ThreadingModel = Field(default_factory=ThreadingModel) bilibili: BilibiliModel = Field(default_factory=BilibiliModel) local_file_server: LocalFileServerModel = Field(default_factory=LocalFileServerModel) + discord: DiscordModel = Field(default_factory=DiscordModel) + logging: LoggingModel = Field(default_factory=LoggingModel) diff --git a/core/managers/reverse_ws_manager.py b/core/managers/reverse_ws_manager.py index c214695..2526b55 100644 --- a/core/managers/reverse_ws_manager.py +++ b/core/managers/reverse_ws_manager.py @@ -317,6 +317,7 @@ class ReverseWSManager: # 为事件注入Bot实例 from ..ws import ReverseWSClient + from .bot_manager import bot_manager # 为每个前端创建独立的Bot实例 with self._bots_lock: @@ -325,6 +326,10 @@ class ReverseWSManager: temp_ws = ReverseWSClient(self, client_id) temp_ws.self_id = event.self_id if hasattr(event, 'self_id') else 0 self.bots[client_id] = Bot(temp_ws) + + # 注册到 BotManager + if event.self_id: + bot_manager.register_bot(self.bots[client_id]) event.bot = self.bots[client_id] @@ -465,7 +470,7 @@ class ReverseWSManager: clients_to_send.append((cid, self.clients[cid])) for cid, websocket in clients_to_send: - await websocket.send(orjson.dumps(payload)) + await websocket.send(orjson.dumps(payload).decode('utf-8')) return await asyncio.wait_for(future, timeout=30.0) except asyncio.TimeoutError: diff --git a/core/utils/logger.py b/core/utils/logger.py index 8b90eed..74b9c83 100644 --- a/core/utils/logger.py +++ b/core/utils/logger.py @@ -8,6 +8,13 @@ import os from pathlib import Path from loguru import logger +# 导入全局配置 +try: + from ..config_loader import global_config + USE_CONFIG = True +except ImportError: + USE_CONFIG = False + # 定义日志格式,添加进程ID和线程ID作为上下文信息 LOG_FORMAT = ( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " @@ -30,14 +37,21 @@ DEBUG_LOG_FORMAT = ( # 移除 loguru 默认的处理器 logger.remove() -# 获取当前环境 -ENVIRONMENT = os.getenv("NEOBOT_ENV", "development") +# 获取日志级别配置 +if USE_CONFIG: + LOG_LEVEL = global_config.logging.level + FILE_LEVEL = global_config.logging.file_level + CONSOLE_LEVEL = global_config.logging.console_level +else: + LOG_LEVEL = "DEBUG" + FILE_LEVEL = "DEBUG" + CONSOLE_LEVEL = "INFO" # 添加控制台输出处理器 logger.add( sys.stderr, - level="INFO" if ENVIRONMENT == "production" else "DEBUG", - format=LOG_FORMAT if ENVIRONMENT == "production" else DEBUG_LOG_FORMAT, + level=CONSOLE_LEVEL, + format=LOG_FORMAT, colorize=True, enqueue=True # 异步写入 ) @@ -50,7 +64,7 @@ log_file_path = log_dir / "{time:YYYY-MM-DD}.log" # 添加文件输出处理器 logger.add( log_file_path, - level="DEBUG", + level=FILE_LEVEL, format=DEBUG_LOG_FORMAT, colorize=False, rotation="00:00", # 每天午夜创建新文件 diff --git a/core/ws.py b/core/ws.py index 0734259..e929baf 100644 --- a/core/ws.py +++ b/core/ws.py @@ -291,7 +291,7 @@ class WS: self._pending_requests[echo_id] = future try: - await self.ws.send(orjson.dumps(payload)) + await self.ws.send(orjson.dumps(payload).decode('utf-8')) return await asyncio.wait_for(future, timeout=30.0) except asyncio.TimeoutError: with self._pending_requests_lock: diff --git a/main.py b/main.py index 5395806..852a978 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ from core.managers.browser_manager import browser_manager from core.utils.executor import run_in_thread_pool, initialize_executor from core.config_loader import global_config as config from core.services.local_file_server import start_local_file_server, stop_local_file_server +from adapters.discord_adapter import DiscordAdapter @@ -143,6 +144,15 @@ async def main(): asyncio.create_task(start_local_file_server()) logger.success(f"本地文件服务器已启动: http://{config.local_file_server.host}:{config.local_file_server.port}") + # 启动 Discord 客户端(如果启用) + discord_client = None + if config.discord.enabled and config.discord.token: + logger.info("正在启动 Discord 客户端...") + discord_client = DiscordAdapter(token=config.discord.token) + asyncio.create_task(discord_client.start_client()) + elif config.discord.enabled: + logger.warning("Discord 已启用,但未配置 Token,跳过启动。") + # 启动文件监控 # 监控 plugins 目录 plugin_path = os.path.join(os.path.dirname(__file__), "plugins") @@ -187,6 +197,9 @@ async def main(): if websocket_client: await websocket_client.close() + if discord_client: + await discord_client.close() + # 关闭反向 WebSocket 服务端 if config.reverse_ws.enabled and reverse_ws_manager.server: await reverse_ws_manager.stop() diff --git a/plugins/cross_platform.py b/plugins/cross_platform.py new file mode 100644 index 0000000..8b8f877 --- /dev/null +++ b/plugins/cross_platform.py @@ -0,0 +1,703 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件 +功能: +- Discord 频道与 QQ 群之间的消息互通 +- 在消息中自动标注来源平台和子频道/群组 ID +- 支持 OneBot v11 协议和数据结构 +- 支持图片、视频等媒体消息 +""" +import asyncio +import json +import re +import time +from typing import Dict, List, Optional, Any +from core.managers.command_manager import matcher +from models.events.message import GroupMessageEvent, PrivateMessageEvent, MessageEvent +from models.message import MessageSegment +from core.permission import Permission +from core.utils.logger import logger +from core.managers.redis_manager import redis_manager + +# --- 配置 --- +# 跨平台映射配置 +# 格式: {discord_channel_id: {"qq_group_id": qq_group_id, "name": "显示名称"}} +CROSS_PLATFORM_MAP: Dict[int, Dict[str, Any]] = { + # 示例配置: + # 123456789012345678: {"qq_group_id": 123456789, "name": "主群"}, + # 987654321098765432: {"qq_group_id": 987654321, "name": "测试群"}, +} + +# Redis 通道名称 +CROSS_PLATFORM_CHANNEL = "neobot_cross_platform" + +# 是否启用跨平台转发 +ENABLE_CROSS_PLATFORM = True + + +def get_platform_info(platform: str, identifier: Any) -> str: + """ + 获取平台信息字符串,用于在消息中标注来源 + + Args: + platform: 平台名称 ('discord' 或 'qq') + identifier: 频道 ID 或群组 ID + + Returns: + 格式化的平台信息字符串 + """ + if platform == "discord": + channel_id = int(identifier) + if channel_id in CROSS_PLATFORM_MAP: + group_info = CROSS_PLATFORM_MAP[channel_id] + group_name = group_info.get("name", f"群组 {group_info['qq_group_id']}") + return f"[Discord {group_name}]" + return f"[Discord]" + elif platform == "qq": + group_id = int(identifier) + return f"[QQ {group_id}]" + return "" + + +async def format_discord_to_qq_content( + discord_username: str, + discord_discriminator: str, + content: str, + channel_id: int, + attachments: List[str] = None +) -> tuple[str, List[str]]: + """ + 将 Discord 消息格式化为 QQ 消息格式 + + Args: + discord_username: Discord 用户名 + discord_discriminator: Discord discriminator (如 #1234) + content: 消息内容 + channel_id: Discord 频道 ID + attachments: 附件列表 + + Returns: + 格式化后的消息内容和图片列表 + """ + platform_info = get_platform_info("discord", channel_id) + + # 构建消息头(简化版,只显示名字) + message_header = f"{platform_info}\n {discord_username}:" + + # 构建消息体 + message_body = content.strip() if content else "" + + # 组合完整消息 + if message_body: + full_message = f"{message_header}\n{message_body}" + else: + full_message = message_header + + return full_message, attachments or [] + + +async def format_qq_to_discord_content( + qq_nickname: str, + qq_user_id: int, + group_name: str, + group_id: int, + content: str, + attachments: List[str] = None +) -> tuple[str, List[str]]: + """ + 将 QQ 消息格式化为 Discord 消息格式 + + Args: + qq_nickname: QQ 昵称 + qq_user_id: QQ 用户 ID + group_name: 群名称 + group_id: QQ 群 ID + content: 消息内容 + attachments: 附件列表 + + Returns: + 格式化后的消息内容和图片列表 + """ + platform_info = get_platform_info("qq", group_id) + + # 构建消息头(简化版,只显示名字) + message_header = f"{platform_info} {qq_nickname}:" + + # 构建消息体 + message_body = content if content else "" + + # 组合完整消息(移除分隔符) + if message_body: + full_message = f"{message_header} {message_body}" + else: + full_message = message_header + + return full_message, attachments or [] + + +async def send_to_discord(channel_id: int, content: str, attachments: List[str] = None): + """ + 发送消息到 Discord 频道 + + 通过 Redis 发布消息,由 Discord 适配器接收并发送 + 这样可以避免跨模块导入实例的问题 + + Args: + channel_id: Discord 频道 ID + content: 消息内容 + attachments: 附件 URL 列表 + """ + try: + publish_data = { + "type": "send_message", + "channel_id": channel_id, + "content": content, + "attachments": attachments or [] + } + await redis_manager.redis.publish("neobot_discord_send", json.dumps(publish_data)) + logger.info(f"[CrossPlatform] 消息已发布到 Redis 供 Discord 适配器发送: {channel_id}") + + except Exception as e: + logger.error(f"[CrossPlatform] 发送消息到 Discord 失败: {e}") + + +async def send_to_qq(group_id: int, content: str, attachments: List[str] = None): + """ + 发送消息到 QQ 群 + + Args: + group_id: QQ 群 ID + content: 消息内容 + attachments: 附件 URL 列表 + """ + try: + from core.managers.bot_manager import bot_manager + from models.message import MessageSegment + + # 获取所有 QQ 机器人实例 + all_bots = bot_manager.get_all_bots() + + if not all_bots: + logger.error(f"[CrossPlatform] 没有可用的 QQ 机器人实例") + return + + logger.debug(f"[CrossPlatform] 找到 {len(all_bots)} 个 QQ 机器人实例") + + for bot in all_bots: + try: + # 构建消息 + message = content + + # 发送消息(如果有附件,使用 OneBot 的图片格式) + if attachments: + # 构建完整消息:文本 + 图片 + from models.message import MessageSegment + full_message = [] + if content: + full_message.append(MessageSegment.text(content)) + for attachment in attachments: + full_message.append(MessageSegment.image(attachment, cache=True, proxy=True, timeout=30)) + + logger.debug(f"[CrossPlatform] 准备发送消息到 QQ 群 {group_id}: {full_message}") + # 一次性发送 + await bot.send_group_msg(group_id, full_message) + logger.info(f"[CrossPlatform] 消息已发送到 QQ 群 {group_id}") + else: + # 只发送文本 + await bot.send_group_msg(group_id, message) + logger.info(f"[CrossPlatform] 消息已发送到 QQ 群 {group_id}") + break # 只需要发送一次 + except Exception as e: + logger.error(f"[CrossPlatform] 发送消息到 QQ 群 {group_id} 失败: {e}") + + except Exception as e: + logger.error(f"[CrossPlatform] 发送消息到 QQ 失败: {e}") + + +async def forward_discord_to_qq( + discord_username: str, + discord_discriminator: str, + content: str, + channel_id: int, + attachments: List[str] = None +): + """ + 将 Discord 消息转发到所有映射的 QQ 群 + + Args: + discord_username: Discord 用户名 + discord_discriminator: Discord discriminator + content: 消息内容 + channel_id: Discord 频道 ID + attachments: 附件列表 + """ + if channel_id not in CROSS_PLATFORM_MAP: + logger.warning(f"[CrossPlatform] 未找到 Discord 频道 {channel_id} 的映射配置") + return + + group_info = CROSS_PLATFORM_MAP[channel_id] + target_qq_group = group_info["qq_group_id"] + + # 格式化消息 + formatted_content, image_list = await format_discord_to_qq_content( + discord_username, + discord_discriminator, + content, + channel_id, + attachments + ) + + # 发送到 QQ + await send_to_qq(target_qq_group, formatted_content, image_list) + + logger.success(f"[CrossPlatform] Discord 频道 {channel_id} -> QQ 群 {target_qq_group}") + + +async def forward_qq_to_discord( + qq_nickname: str, + qq_user_id: int, + group_name: str, + group_id: int, + content: str, + attachments: List[str] = None +): + """ + 将 QQ 消息转发到所有映射的 Discord 频道 + + Args: + qq_nickname: QQ 昵称 + qq_user_id: QQ 用户 ID + group_name: 群名称 + group_id: QQ 群 ID + content: 消息内容 + attachments: 附件列表 + """ + # 查找映射的 Discord 频道 + target_channels = [] + for discord_channel_id, info in CROSS_PLATFORM_MAP.items(): + if info["qq_group_id"] == group_id: + target_channels.append(discord_channel_id) + + if not target_channels: + logger.warning(f"[CrossPlatform] 未找到 QQ 群 {group_id} 的映射配置") + return + + # 格式化消息 + formatted_content, image_list = await format_qq_to_discord_content( + qq_nickname, + qq_user_id, + group_name, + group_id, + content, + attachments + ) + + # 发送到所有映射的 Discord 频道 + for channel_id in target_channels: + await send_to_discord(channel_id, formatted_content, image_list) + + logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}") + + +async def publish_to_redis(platform: str, data: dict): + """ + 通过 Redis 发布跨平台消息 + + Args: + platform: 平台名称 + data: 消息数据 + """ + try: + if redis_manager.redis: + publish_data = { + "platform": platform, + "data": data, + "timestamp": int(__import__('time').time()) + } + await redis_manager.redis.publish(CROSS_PLATFORM_CHANNEL, json.dumps(publish_data)) + logger.debug(f"[CrossPlatform] 已通过 Redis 发布消息: platform={platform}") + except Exception as e: + logger.error(f"[CrossPlatform] Redis 发布失败: {e}") + + +async def handle_discord_message( + username: str, + discriminator: str, + content: str, + channel_id: int, + attachments: List[str] = None +): + """ + 处理 Discord 消息并转发 + + Args: + username: Discord 用户名 + discriminator: Discord discriminator + content: 消息内容 + channel_id: Discord 频道 ID + attachments: 附件列表 + """ + if not ENABLE_CROSS_PLATFORM: + return + + logger.info(f"[CrossPlatform] 收到 Discord 消息: {username}#{discriminator} in {channel_id}") + + # 转发到映射的 QQ 群 + 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[str] = None +): + """ + 处理 QQ 消息并转发 + + Args: + nickname: QQ 昵称 + user_id: QQ 用户 ID + group_name: 群名称 + group_id: QQ 群 ID + content: 消息内容 + attachments: 附件列表 + """ + if not ENABLE_CROSS_PLATFORM: + return + + logger.info(f"[CrossPlatform] 收到 QQ 消息: {nickname} ({user_id}) in {group_name}({group_id})") + + # 转发到映射的 Discord 频道 + 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 ENABLE_CROSS_PLATFORM: + return + + # 检查是否是映射的群组 + group_id = event.group_id + mapped_channel = None + for discord_channel_id, info in 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): + 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") + if file_url: + attachments.append(str(file_url)) + elif segment.type == "video": + file_url = segment.data.get("url") or segment.data.get("file") + if file_url: + attachments.append(str(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 + + # 清理多余空白 + 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 ENABLE_CROSS_PLATFORM: + return + + # 检查事件是否包含 Discord 特定信息 + if not hasattr(event, '_is_discord_message'): + return + + discord_channel_id = getattr(event, 'discord_channel_id', None) + if discord_channel_id is None: + return + + # 提取消息内容 + content = event.raw_message or "" + attachments = [] + + # 从 raw_message 中提取附件 URL(Discord 附件已添加到 raw_message) + import re + url_pattern = r'https?://[^\s<>"]+|www\.\S+' + raw_message_lines = content.split('\n') + content_lines = [] + + for line in raw_message_lines: + line = line.strip() + if re.match(url_pattern, line): + # 这是附件 URL + if line not in attachments: + attachments.append(line) + else: + # 这是普通文本内容 + if line: + content_lines.append(line) + + content = '\n'.join(content_lines).strip() + + # 从 message 列表中提取(备用方案) + if hasattr(event, 'message') and isinstance(event.message, list): + for segment in event.message: + if isinstance(segment, MessageSegment): + if segment.type == "text": + pass # 已经在 raw_message 中 + elif segment.type == "image": + file_url = segment.data.get("url") or segment.data.get("file") + if file_url and str(file_url) not in attachments: + attachments.append(str(file_url)) + elif segment.type == "video": + file_url = segment.data.get("url") or segment.data.get("file") + if file_url and str(file_url) not in attachments: + attachments.append(str(file_url)) + + # 获取用户信息 + discord_username = getattr(event, 'discord_username', 'Unknown') + discord_discriminator = getattr(event, 'discord_discriminator', '') + + # 处理消息 + await handle_discord_message( + username=discord_username, + discriminator=discord_discriminator, + content=content, + channel_id=discord_channel_id, + attachments=attachments + ) + + +async def cross_platform_subscription_loop(): + """ + Redis 跨平台消息订阅循环 + """ + if redis_manager.redis is None: + logger.warning("[CrossPlatform] Redis 未初始化,无法启动订阅") + return + + try: + pubsub = redis_manager.redis.pubsub() + await pubsub.subscribe(CROSS_PLATFORM_CHANNEL) + + logger.success("[CrossPlatform] 已订阅 Redis 跨平台频道") + + async for message in pubsub.listen(): + if message["type"] == "message": + try: + data = json.loads(message["data"]) + platform = data.get("platform", "") + message_data = data.get("data", {}) + + logger.info(f"[CrossPlatform] 收到跨平台消息: {platform}") + + if platform == "discord": + # 从 Discord 转发到 QQ + await forward_discord_to_qq( + discord_username=message_data.get("username", "Unknown"), + discord_discriminator=message_data.get("discriminator", ""), + content=message_data.get("content", ""), + channel_id=message_data.get("channel_id", 0), + attachments=message_data.get("attachments", []) + ) + elif platform == "qq": + # 从 QQ 转发到 Discord + await forward_qq_to_discord( + qq_nickname=message_data.get("nickname", "Unknown"), + qq_user_id=message_data.get("user_id", 0), + group_name=message_data.get("group_name", ""), + group_id=message_data.get("group_id", 0), + content=message_data.get("content", ""), + attachments=message_data.get("attachments", []) + ) + + except json.JSONDecodeError as e: + logger.error(f"[CrossPlatform] 解析消息失败: {e}") + except Exception as e: + logger.error(f"[CrossPlatform] 处理跨平台消息失败: {e}") + + except Exception as e: + logger.error(f"[CrossPlatform] 订阅循环异常: {e}") + + +# 全局订阅任务 +_subscription_task = None + + +async def start_cross_platform_subscription(): + """ + 启动跨平台消息订阅 + """ + global _subscription_task + + if _subscription_task is None and ENABLE_CROSS_PLATFORM: + _subscription_task = asyncio.create_task(cross_platform_subscription_loop()) + logger.success("[CrossPlatform] 跨平台消息订阅已启动") + + +async def stop_cross_platform_subscription(): + """ + 停止跨平台消息订阅 + """ + global _subscription_task + + if _subscription_task: + _subscription_task.cancel() + try: + await _subscription_task + except asyncio.CancelledError: + pass + _subscription_task = None + logger.info("[CrossPlatform] 跨平台消息订阅已停止") + + +async def reload_config(): + """ + 重新加载配置 + """ + global CROSS_PLATFORM_MAP, ENABLE_CROSS_PLATFORM + + try: + import os + config_path = os.path.join(os.path.dirname(__file__), "..", "config.toml") + + if os.path.exists(config_path): + try: + import tomllib + except ImportError: + import tomli as tomllib + + with open(config_path, "rb") as f: + config = tomllib.load(f) + + cross_platform_config = config.get("cross_platform", {}) + ENABLE_CROSS_PLATFORM = cross_platform_config.get("enabled", True) + + # 重新加载映射配置(支持两种格式) + mappings = cross_platform_config.get("mappings", {}) + CROSS_PLATFORM_MAP = {} + + # 格式1: [cross_platform.mappings.123456789012345678] 子表形式 + if isinstance(mappings, dict) and mappings: + for key, value in mappings.items(): + if isinstance(value, dict) and "qq_group_id" in value: + try: + discord_id = int(key) if str(key).isdigit() else int(str(key).split('.')[-1]) + CROSS_PLATFORM_MAP[discord_id] = { + "qq_group_id": int(value.get("qq_group_id", 0)), + "name": value.get("name", "") + } + except (ValueError, AttributeError): + continue + + # 格式2: 旧的字典形式(向后兼容) + if not CROSS_PLATFORM_MAP: + for key, value in mappings.items(): + if isinstance(key, str) and key.isdigit(): + CROSS_PLATFORM_MAP[int(key)] = { + "qq_group_id": int(value.get("qq_group_id", 0)), + "name": value.get("name", "") + } + + logger.success(f"[CrossPlatform] 配置已重新加载: {len(CROSS_PLATFORM_MAP)} 个映射") + + except Exception as e: + logger.error(f"[CrossPlatform] 重新加载配置失败: {e}") + + +# 插件加载时自动启动和加载配置 +import asyncio +try: + asyncio.create_task(reload_config()) +except Exception as e: + logger.error(f"[CrossPlatform] 重新加载配置失败: {e}") + +try: + asyncio.create_task(start_cross_platform_subscription()) +except Exception as e: + logger.error(f"[CrossPlatform] 启动订阅失败: {e}") + + +# 命令处理器 +@matcher.command("cross_config", "跨平台配置", permission=Permission.ADMIN) +async def cross_config_command(event: MessageEvent): + """ + 查看跨平台配置 + """ + if not ENABLE_CROSS_PLATFORM: + await event.reply("跨平台功能已禁用") + return + + config_lines = ["=== 跨平台映射配置 ==="] + + if not CROSS_PLATFORM_MAP: + config_lines.append("当前没有配置任何映射") + else: + for discord_id, info in 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 reload_config() + await event.reply("跨平台配置已重载") + + +# 清理函数 +def cleanup(): + """清理资源""" + asyncio.create_task(stop_cross_platform_subscription()) diff --git a/test_image_fix.py b/test_image_fix.py new file mode 100644 index 0000000..7404d2e --- /dev/null +++ b/test_image_fix.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""测试图片处理修复""" + +import sys +sys.path.insert(0, '.') + +print("测试 1: 检查 discord_adapter.py 导入") +try: + from adapters.discord_adapter import DiscordAdapter + print("✓ discord_adapter.py 导入成功") +except Exception as e: + print(f"✗ discord_adapter.py 导入失败: {e}") + +print("\n测试 2: 检查 cross_platform.py 导入") +try: + import plugins.cross_platform as cp + print("✓ cross_platform.py 导入成功") +except Exception as e: + print(f"✗ cross_platform.py 导入失败: {e}") + +print("\n测试 3: 检查 router.py 导入") +try: + from adapters.router import DiscordToOneBotConverter + print("✓ router.py 导入成功") +except Exception as e: + print(f"✗ router.py 导入失败: {e}") + +print("\n测试 4: 检查 MessageSegment 导入") +try: + from models.message import MessageSegment + print("✓ MessageSegment 导入成功") +except Exception as e: + print(f"✗ MessageSegment 导入失败: {e}") + +print("\n所有测试完成!")