From eebb4ae75e3a4ccc6a90b097934ef584a4b6f983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?= <148796996+K2cr2O1@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:49:15 +0800 Subject: [PATCH 1/2] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 3aacd20..458b453 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,9 @@ ### 核心特性 * **模块化插件架构**:所有功能都在 `plugins/` 目录,开发者可轻松扩展 -* **极致性能优化**: +* **性能优化**: * **Python 3.14 JIT**:运行时热点代码编译成机器码 * **Mypyc AOT编译**:核心模块编译为C扩展 - * **Playwright 页面池**:浏览器页面预热池,降低截图延迟 * **全局连接复用**:HTTP 和 Redis 连接池化管理 * **开发者友好**:完整的类型提示,清晰的 API 设计 * **集成 Redis 缓存**:缓存帮助图片、权限数据、会话状态等 From 51fb77e6e0fc7f74f5bace0128e0647dcfa695dc Mon Sep 17 00:00:00 2001 From: K2Cr2O1 <2221577113@qq.com> Date: Sat, 21 Mar 2026 13:44:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=B6=88=E6=81=AF=E4=BA=92=E9=80=9A=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8F=8A=E9=80=82=E9=85=8D=E5=99=A8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(discord_adapter): 优化音频处理与心跳机制 feat(plugins/discord-cross): 实现QQ与Discord消息互通功能 fix(events/base): 添加platform字段到基础事件模型 --- MULTI_FRONTEND_SUPPORT.md | 175 ----- REVERSE_WS_LOAD_BALANCE.md | 211 ----- adapters/discord_adapter.py | 196 ++++- adapters/router.py | 451 +++++++---- adapters/universal_model.py | 101 --- check_syntax.py | 70 -- models/events/base.py | 2 + performance_config_example.py | 75 -- plugins/cross_platform.py | 1015 ------------------------- plugins/discord-cross/__init__.py | 24 + plugins/discord-cross/config.py | 61 ++ plugins/discord-cross/handlers.py | 229 ++++++ plugins/discord-cross/parser.py | 364 +++++++++ plugins/discord-cross/sender.py | 165 ++++ plugins/discord-cross/subscription.py | 81 ++ plugins/discord-cross/translator.py | 152 ++++ plugins/osu!_plugin/__init__.py | 0 plugins/osu!_plugin/test.py | 11 + profile_main.py | 93 --- pytest.ini | 2 - setup_mypyc.py | 117 --- test_image_fix.py | 36 - test_performance_simple.py | 79 -- 23 files changed, 1562 insertions(+), 2148 deletions(-) delete mode 100644 MULTI_FRONTEND_SUPPORT.md delete mode 100644 REVERSE_WS_LOAD_BALANCE.md delete mode 100644 adapters/universal_model.py delete mode 100644 check_syntax.py delete mode 100644 performance_config_example.py delete mode 100644 plugins/cross_platform.py create mode 100644 plugins/discord-cross/__init__.py create mode 100644 plugins/discord-cross/config.py create mode 100644 plugins/discord-cross/handlers.py create mode 100644 plugins/discord-cross/parser.py create mode 100644 plugins/discord-cross/sender.py create mode 100644 plugins/discord-cross/subscription.py create mode 100644 plugins/discord-cross/translator.py create mode 100644 plugins/osu!_plugin/__init__.py create mode 100644 plugins/osu!_plugin/test.py delete mode 100644 profile_main.py delete mode 100644 pytest.ini delete mode 100644 setup_mypyc.py delete mode 100644 test_image_fix.py delete mode 100644 test_performance_simple.py diff --git a/MULTI_FRONTEND_SUPPORT.md b/MULTI_FRONTEND_SUPPORT.md deleted file mode 100644 index c999cd2..0000000 --- a/MULTI_FRONTEND_SUPPORT.md +++ /dev/null @@ -1,175 +0,0 @@ -# 多前端支持问题分析和解决方案 - -## 已实现的功能 - -### 1. 负载均衡 -- ✅ 自动选择负载最低的健康客户端 -- ✅ 健康检查(30秒内有活动) -- ✅ 负载计数(消息处理次数) -- ✅ API调用时自动切换客户端 - -### 2. 防重复发送 -- ✅ 事件ID检查(`id`/`post_id`/`time`) -- ✅ 消息锁机制(异步锁) -- ✅ 双重检查(锁内外各一次) -- ✅ 自动清理过期数据 - -### 3. 多前端支持 -- ✅ 每个前端独立的Bot实例 -- ✅ 客户端连接/断开管理 -- ✅ 完整的清理机制 - -## 已修复的问题 - -### 1. 清理不完整 -**问题**:客户端断开连接时没有清理负载计数和健康状态 - -**解决方案**: -```python -async def _disconnect_client(self, client_id: str) -> None: - if client_id in self.clients: - del self.clients[client_id] - if client_id in self.client_self_ids: - del self.client_self_ids[client_id] - if client_id in self._client_load: - del self._client_load[client_id] - if client_id in self._client_health: - del self._client_health[client_id] - if client_id in self.bots: - del self.bots[client_id] -``` - -### 2. Bot实例共享 -**问题**:多个前端共享同一个Bot实例,可能导致状态混乱 - -**解决方案**:为每个前端创建独立的Bot实例 -```python -# Bot实例字典(每个前端独立的Bot实例) -self.bots: Dict[str, Bot] = {} - -# 为每个前端创建独立的Bot实例 -if client_id not in self.bots: - temp_ws = WS() - temp_ws.self_id = event.self_id if hasattr(event, 'self_id') else 0 - self.bots[client_id] = Bot(temp_ws) - -event.bot = self.bots[client_id] -``` - -### 3. 清理逻辑错误 -**问题**:清理过期数据时,将Lock对象当作时间戳来处理 - -**解决方案**:分离存储Lock对象和时间戳 -```python -# 分离存储 -self._message_locks: Dict[str, asyncio.Lock] = {} # 存储Lock对象 -self._message_lock_times: Dict[str, datetime] = {} # 存储Lock的创建时间 - -# 清理时使用时间戳字典 -expired_locks = [ - lock_key for lock_key, timestamp in self._message_lock_times.items() - if (current_time - timestamp).total_seconds() > self._lock_ttl -] -``` - -## 可能存在的问题 - -### 1. API响应混淆 -**问题描述**:当多个前端同时响应时,`_pending_requests`是全局共享的,可能导致响应匹配错误 - -**当前解决方案**:使用echo_id匹配响应,任何前端的响应都会被正确匹配 -```python -echo_id = data.get("echo") -if echo_id and echo_id in self._pending_requests: - future = self._pending_requests.pop(echo_id) - if not future.done(): - future.set_result(data) -``` - -**潜在风险**:如果多个前端同时响应同一个API请求,只有第一个响应会被处理 - -**建议优化**:可以考虑在API调用时指定客户端ID,确保响应来自正确的客户端 - -### 2. 负载均衡不准确 -**问题描述**:负载计数可能不准确,因为多个前端可能同时处理相同的消息 - -**当前解决方案**:负载计数只是参考,系统会优先选择健康的客户端 - -**建议优化**:可以考虑使用更复杂的负载均衡策略,如加权轮询 - -### 3. Bot实例状态 -**问题描述**:每个前端有独立的Bot实例,可能导致状态不一致 - -**当前解决方案**:Bot实例是无状态的,只依赖于WS实例和self_id - -**建议优化**:如果需要共享状态,可以使用Redis等外部存储 - -## 最佳实践 - -### 1. 部署建议 -- 部署2-3个前端实例进行负载均衡 -- 确保前端实例之间的网络连接稳定 -- 定期检查前端连接状态 - -### 2. 监控建议 -- 关注重复事件日志,排查网络问题 -- 监控客户端健康状态 -- 监控API调用成功率 - -### 3. 调试建议 -- 使用`get_healthy_clients()`查看健康客户端 -- 使用`get_client_with_least_load()`查看负载最低的客户端 -- 查看日志了解API调用情况 - -## 性能优化建议 - -### 1. API响应过滤 -可以在API调用时记录客户端ID,确保响应来自正确的客户端: -```python -# 记录API请求的客户端ID -self._api_requests[echo_id] = { - 'client_id': client_id, - 'timestamp': datetime.now() -} - -# 处理响应时验证客户端ID -if echo_id in self._api_requests: - request_info = self._api_requests[echo_id] - if request_info['client_id'] == client_id: - # 处理响应 -``` - -### 2. 负载均衡策略 -可以考虑使用更复杂的负载均衡策略: -- 加权轮询:根据客户端性能分配权重 -- 最少连接:选择连接数最少的客户端 -- 响应时间:选择响应时间最短的客户端 - -### 3. 缓存优化 -可以考虑使用Redis缓存Bot实例的状态: -```python -# 缓存Bot实例的状态 -await redis_manager.set(f"neobot:bot:{client_id}:status", status_data, ex=300) -``` - -## 故障排查 - -### 问题:消息重复处理 -**原因**:网络延迟导致前端重复发送 - -**解决**:系统已自动处理,检查事件ID是否正确设置 - -### 问题:API调用超时 -**原因**:选择的客户端不健康或网络问题 - -**解决**:系统会自动切换到其他健康客户端 - -### 问题:所有客户端都不健康 -**原因**:前端断开连接或网络问题 - -**解决**:检查前端连接状态和网络连接 - -### 问题:Bot实例初始化失败 -**原因**:WS实例缺失或self_id未设置 - -**解决**:确保在事件处理时正确设置self_id diff --git a/REVERSE_WS_LOAD_BALANCE.md b/REVERSE_WS_LOAD_BALANCE.md deleted file mode 100644 index 25ed48e..0000000 --- a/REVERSE_WS_LOAD_BALANCE.md +++ /dev/null @@ -1,211 +0,0 @@ -# 反向 WebSocket 负载均衡配置 - -## 功能特性 - -### 1. 负载均衡 - -当有多个前端(NapCat等)连接到反向WebSocket服务端时,系统会自动进行负载均衡: - -- **自动选择负载最低的客户端**:API调用时会自动选择负载最低的健康客户端 -- **健康检查**:系统会记录每个客户端的最后活动时间,只选择最近30秒内有活动的客户端 -- **负载计数**:每个客户端的消息处理次数会被记录,用于负载均衡计算 - -### 2. 防重复发送 - -系统实现了多层防重复机制: - -- **事件ID检查**:通过事件ID(`id`、`post_id`或`time`)识别重复事件 -- **消息锁机制**:使用异步锁防止同一消息被并发处理 -- **双重检查**:在锁内再次检查是否重复,防止并发竞争条件 -- **自动清理**:定期清理过期的事件ID和消息锁(默认60秒和300秒) - -### 3. 工作原理 - -``` -┌─────────────┐ -│ Frontend │ -│ (NapCat) │ -└──────┬──────┘ - │ - │ WebSocket - │ -┌──────▼──────┐ -│ │ -│ ReverseWS │ ←── 负载均衡 + 防重复 -│ Manager │ -│ │ -└──────┬──────┘ - │ - │ 处理事件 - │ -┌──────▼──────┐ -│ Command │ -│ Manager │ -│ │ -└─────────────┘ -``` - -## 配置说明 - -在 `config.toml` 中配置: - -```toml -[reverse_ws] -enabled = true # 启用反向WebSocket -host = "0.0.0.0" # 监听地址 -port = 3002 # 监听端口 -token = "" # 访问令牌(可选) -``` - -## 使用方法 - -### 启动配置 - -1. 在 `config.toml` 中设置 `enabled = true` -2. 确保防火墙允许指定端口的连接 -3. 启动机器人服务 - -### 前端配置 - -在 NapCat 等前端配置中,将 WebSocket 连接地址改为: - -``` -ws://your-server-ip:3002 -``` - -多个前端可以连接到同一个地址,系统会自动进行负载均衡。 - -## API 调用 - -### 使用负载均衡(推荐) - -```python -from core.managers import reverse_ws_manager - -# 自动选择负载最低的健康客户端 -response = await reverse_ws_manager.call_api( - action="send_group_msg", - params={ - "group_id": 123456, - "message": "Hello" - }, - use_load_balance=True # 默认为 True -) -``` - -### 指定客户端 - -```python -# 向特定客户端发送 -response = await reverse_ws_manager.call_api( - action="send_group_msg", - params={ - "group_id": 123456, - "message": "Hello" - }, - client_id="specific-client-id", - use_load_balance=False -) -``` - -### 获取客户端信息 - -```python -# 获取所有连接的客户端 -clients = reverse_ws_manager.get_connected_clients() - -# 获取健康的客户端(最近30秒有活动) -healthy = reverse_ws_manager.get_healthy_clients() - -# 获取负载最低的客户端 -least_load = reverse_ws_manager.get_client_with_least_load() -``` - -## 负载均衡策略 - -系统采用以下策略选择客户端: - -1. **健康检查**:只选择最近30秒内有活动的客户端 -2. **负载计数**:在健康客户端中选择负载最低的 -3. **自动切换**:如果负载最低的客户端不健康,自动选择下一个 - -## 防重复机制 - -### 事件ID检查 - -系统通过以下方式识别事件: - -- 优先使用 `id` 字段 -- 其次使用 `post_id` 字段 -- 最后使用 `time` 字段 - -### 消息锁 - -消息处理使用异步锁,防止并发重复处理: - -```python -async with self._get_message_lock(message_key): - # 处理消息 - await matcher.handle_event(None, event) -``` - -### 自动清理 - -系统每10秒清理一次过期数据: - -- 事件ID保留时间:60秒 -- 消息锁保留时间:300秒 - -## 监控和调试 - -### 查看客户端状态 - -```python -# 查看所有客户端 -print("所有客户端:", reverse_ws_manager.get_connected_clients()) - -# 查看健康客户端 -print("健康客户端:", reverse_ws_manager.get_healthy_clients()) - -# 查看负载情况 -print("客户端负载:", reverse_ws_manager._client_load) - -# 查看健康时间 -print("客户端健康时间:", reverse_ws_manager._client_health) -``` - -### 日志输出 - -系统会输出以下日志: - -- 客户端连接/断开 -- 检测到重复事件 -- 负载均衡选择 -- API调用结果 - -## 最佳实践 - -1. **多前端部署**:建议部署2-3个前端实例进行负载均衡 -2. **健康检查**:定期检查前端连接状态 -3. **监控日志**:关注重复事件日志,排查网络问题 -4. **合理设置TTL**:根据消息频率调整事件ID保留时间 - -## 故障排查 - -### 问题:消息重复处理 - -**原因**:网络延迟导致前端重复发送 - -**解决**:检查事件ID是否正确设置,系统已自动处理 - -### 问题:API调用超时 - -**原因**:选择的客户端不健康或网络问题 - -**解决**:系统会自动切换到其他健康客户端 - -### 问题:所有客户端都不健康 - -**原因**:前端断开连接或网络问题 - -**解决**:检查前端连接状态和网络连接 diff --git a/adapters/discord_adapter.py b/adapters/discord_adapter.py index 0892223..3f80e8f 100644 --- a/adapters/discord_adapter.py +++ b/adapters/discord_adapter.py @@ -3,14 +3,16 @@ Discord 适配器 (Discord Adapter) 此模块负责与 Discord API 建立连接,接收 Discord 消息, -并将其转换为通用数据模型 (Universal Data Models), -同时提供将通用消息段发送回 Discord 的能力。 +并将其转换为本地 OneBot 数据模型, +同时提供将本地消息段发送回 Discord 的能力。 """ import asyncio import json import os import io import requests +import tempfile +import subprocess from typing import Union, List, Optional try: @@ -61,6 +63,8 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): """当 Bot 成功连接到 Discord 时触发""" self.logger.success(f"Discord Bot 已登录: {self.user} (ID: {self.user.id})") + self.start_heartbeat_task(interval=30) + # 启动 Redis 订阅以处理跨平台消息 asyncio.create_task(self.start_redis_subscription()) @@ -112,6 +116,61 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): except Exception as e: self.logger.error(f"[DiscordAdapter] Redis 订阅异常: {e}") + async def convert_to_ogg_opus(self, audio_bytes: bytes) -> Optional[bytes]: + """ + 将音频文件转换为 OGG Opus 格式,用于 Discord 语音消息 + """ + try: + # 创建临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") as temp_in: + temp_in.write(audio_bytes) + temp_in_path = temp_in.name + + with tempfile.NamedTemporaryFile(delete=False, suffix=".ogg") as temp_out: + temp_out_path = temp_out.name + + # 使用 ffmpeg 转换 + # -c:a libopus: 使用 Opus 编码器 + # -b:a 64k: 比特率 64k + # -vbr on: 开启可变比特率 + # -compression_level 10: 最高压缩级别 + # -frame_duration 20: 帧时长 20ms + # -application voip: 针对语音优化 + cmd = [ + "ffmpeg", "-y", "-i", temp_in_path, + "-c:a", "libopus", "-b:a", "64k", "-vbr", "on", + "-compression_level", "10", "-frame_duration", "20", + "-application", "voip", temp_out_path + ] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + with open(temp_out_path, "rb") as f: + ogg_bytes = f.read() + return ogg_bytes + else: + self.logger.error(f"[DiscordAdapter] ffmpeg 转换失败: {stderr.decode('utf-8', errors='ignore')}") + return None + + except Exception as e: + self.logger.error(f"[DiscordAdapter] 音频转换异常: {e}") + return None + finally: + # 清理临时文件 + try: + if os.path.exists(temp_in_path): + os.remove(temp_in_path) + if os.path.exists(temp_out_path): + os.remove(temp_out_path) + except: + pass + async def handle_send_message(self, data: dict): """处理来自 Redis 的消息发送请求""" try: @@ -131,6 +190,10 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}") + embed = None + if embed_data: + embed = discord.Embed.from_dict(embed_data) + files = [] if attachments: proxies = None @@ -153,14 +216,60 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): response = requests.get(attachment_url, proxies=proxies, timeout=30) if not filename: filename = os.path.basename(attachment_url.split('?')[0]) or "attachment" - files.append(discord.File(fp=io.BytesIO(response.content), filename=filename)) + + # 检查是否是语音文件 + is_voice = filename.lower().endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')) + + if is_voice: + # 尝试转换为 OGG Opus + ogg_bytes = await self.convert_to_ogg_opus(response.content) + if ogg_bytes: + # 转换成功,作为语音消息发送 + # discord.py 官方 API 目前不支持直接发送语音消息 + # 我们需要使用内部的 HTTP 客户端来发送 + try: + # 构造文件数据 + file_data = { + "name": "voice-message.ogg", + "value": ogg_bytes, + "content_type": "audio/ogg" + } + + # 构造 payload + payload = { + "flags": 8192 # IS_VOICE_MESSAGE + } + + if content: + payload["content"] = content + content = "" # 清空 content,避免重复发送 + + if embed: + payload["embeds"] = [embed.to_dict()] + embed = None # 清空 embed,避免重复发送 + + # 使用内部 HTTP 客户端发送 + route = discord.http.Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) + await self.http.request( + route, + form=[ + {'name': 'payload_json', 'value': json.dumps(payload)}, + {'name': 'files[0]', 'value': ogg_bytes, 'filename': 'voice-message.ogg', 'content_type': 'audio/ogg'} + ] + ) + self.logger.success(f"[DiscordAdapter] 语音消息已发送到频道 {channel_id}") + continue # 跳过后面的普通发送逻辑 + except Exception as e: + self.logger.error(f"[DiscordAdapter] 发送语音消息失败: {e},将作为普通文件发送") + files.append(discord.File(fp=io.BytesIO(ogg_bytes), filename="voice.ogg")) + else: + # 转换失败,作为普通文件发送 + files.append(discord.File(fp=io.BytesIO(response.content), filename=filename)) + else: + files.append(discord.File(fp=io.BytesIO(response.content), filename=filename)) except Exception as e: self.logger.error(f"[DiscordAdapter] 下载附件失败: {attachment_url}, 错误: {e}") - embed = None - if embed_data: - embed = discord.Embed.from_dict(embed_data) - if content or files or embed: await channel.send(content=content, files=files if files else None, embed=embed) @@ -169,14 +278,73 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): except Exception as e: self.logger.error(f"[DiscordAdapter] 发送消息失败: {e}") - async def start_client(self): - """启动 Discord 客户端(非阻塞方式)""" + async def start_client(self, max_retries: int = -1, retry_delay: int = 5): + """ + 启动 Discord 客户端 + + Args: + max_retries: 最大重连次数,-1 表示无限重连 + retry_delay: 重连延迟(秒) + """ 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}") + retry_count = 0 + + while max_retries == -1 or retry_count < max_retries: + try: + self.logger.info("正在连接 Discord...") + await self.start(self.token) + except asyncio.CancelledError: + self.logger.info("连接被取消") + break + except Exception as e: + retry_count += 1 + 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 '无限'})...") + # 清理旧的连接状态 + self.clear() + await asyncio.sleep(retry_delay) + + self.logger.info("Discord 客户端已停止") + + async def start_heartbeat(self, interval: int = 30): + """ + 启动心跳机制,定期检查连接状态 + + Args: + interval: 心跳间隔(秒) + """ + self.logger.info(f"心跳机制已启动,间隔: {interval}秒") + + while self.is_closed() is False: + try: + await asyncio.sleep(interval) + + if self.ws is not None and self.ws.closed: + self.logger.warning("检测到 WebSocket 连接已关闭,触发重连...") + await self.close() + break + + self.logger.debug(f"心跳正常: {self.user}") + + except Exception as e: + self.logger.error(f"心跳检测异常: {e}") + break + + def start_heartbeat_task(self, interval: int = 30): + """ + 启动心跳任务(非阻塞) + + Args: + interval: 心跳间隔(秒) + """ + if not hasattr(self, 'heartbeat_task') or self.heartbeat_task.done(): + self.heartbeat_task = asyncio.create_task(self.start_heartbeat(interval)) + self.logger.info("心跳任务已启动") diff --git a/adapters/router.py b/adapters/router.py index 074ab58..b182876 100644 --- a/adapters/router.py +++ b/adapters/router.py @@ -12,7 +12,7 @@ 4. 将插件返回的 OneBot `MessageSegment` 转换为 Discord 格式并发送。 """ import asyncio -from typing import Union, List, Any, Optional +from typing import Union, List, Any, Optional, Dict try: import discord @@ -27,6 +27,189 @@ 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 消息事件的转换器。 @@ -53,13 +236,85 @@ class DiscordToOneBotConverter: # 我们需要把前面的 @ 提及去掉,否则命令匹配器 (matcher) 无法识别以 "/" 开头的命令 raw_message = discord_message.content - # 添加附件信息到 raw_message + # 构造 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( @@ -72,9 +327,6 @@ class DiscordToOneBotConverter: # 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 @@ -89,6 +341,7 @@ class DiscordToOneBotConverter: event = PrivateMessageEvent( time=current_time, self_id=self_id, + platform="discord", message_type="private", sub_type="friend", message_id=message_id, @@ -103,6 +356,7 @@ class DiscordToOneBotConverter: event = GroupMessageEvent( time=current_time, self_id=self_id, + platform="discord", message_type="group", sub_type="normal", message_id=message_id, @@ -119,146 +373,14 @@ class DiscordToOneBotConverter: 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() + # 注入 DiscordBotWrapper + event.bot = DiscordBotWrapper(adapter) return event @staticmethod - async def send_discord_reply( - original_message: 'discord.Message', + async def send_discord_message( + channel: 'discord.abc.Messageable', message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], adapter: Any ): @@ -266,7 +388,7 @@ class DiscordToOneBotConverter: 将 OneBot 的消息段转换为 Discord 格式并发送。 Args: - original_message: 触发此回复的原始 Discord 消息 + channel: Discord 频道对象 (TextChannel, DMChannel 等) message: 插件返回的 OneBot 消息内容 (字符串或 MessageSegment 列表) adapter: DiscordAdapter 实例 """ @@ -306,29 +428,33 @@ class DiscordToOneBotConverter: k, v = param.split('=', 1) params[k] = v - if cq_type in ("image", "video"): + 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): + 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"): + 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: - image_bytes = base64.b64decode(b64_data) - files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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}") + 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": @@ -349,8 +475,8 @@ class DiscordToOneBotConverter: if seg_type == "text": content += seg_data.get("text", "") - elif seg_type == "image" or seg_type == "video": - # OneBot 的图片/视频通常有 file (URL或本地路径) 或 url 字段 + 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: @@ -358,32 +484,37 @@ class DiscordToOneBotConverter: if isinstance(file_url, bytes): import io try: - files.append(discord.File(fp=io.BytesIO(file_url), filename="image.png")) + 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}") + 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 图片 (需要解码并作为文件上传) + 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"): + 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: - image_bytes = base64.b64decode(b64_data) - files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) + 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}") + 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": @@ -399,7 +530,7 @@ class DiscordToOneBotConverter: try: # 如果内容为空但有文件,Discord 允许发送 if content or files: - await original_message.channel.send(content=content, files=files if files else None) + await channel.send(content=content, files=files if files else None) else: logger.warning("尝试发送空消息到 Discord,已拦截") except Exception as e: diff --git a/adapters/universal_model.py b/adapters/universal_model.py deleted file mode 100644 index 7afc5e3..0000000 --- a/adapters/universal_model.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- 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/check_syntax.py b/check_syntax.py deleted file mode 100644 index 2098eab..0000000 --- a/check_syntax.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -""" -检查项目中所有Python文件的语法 -""" -import os -import sys - -def check_python_syntax(file_path): - """ - 检查单个Python文件的语法 - - Args: - file_path: Python文件路径 - - Returns: - bool: 如果语法正确返回True,否则返回False - """ - try: - with open(file_path, 'rb') as f: - code = f.read() - - # 使用compile函数检查语法 - compile(code, file_path, 'exec') - return True - except SyntaxError as e: - print(f"语法错误: {file_path}:{e.lineno}:{e.offset}: {e.msg}") - return False - except Exception as e: - print(f"无法检查文件 {file_path}: {e}") - return False - -def main(): - """ - 检查项目中所有Python文件的语法 - """ - # 要检查的目录 - directories = ['core', 'models', 'plugins', 'scripts', 'tests'] - - # 要检查的单独文件 - files = ['main.py', 'profile_main.py', 'test_performance_simple.py', 'setup_mypyc.py'] - - error_count = 0 - file_count = 0 - - # 检查目录中的所有Python文件 - for directory in directories: - for root, _, filenames in os.walk(directory): - for filename in filenames: - if filename.endswith('.py'): - file_path = os.path.join(root, filename) - file_count += 1 - if not check_python_syntax(file_path): - error_count += 1 - - # 检查单独的Python文件 - for file in files: - if os.path.exists(file): - file_count += 1 - if not check_python_syntax(file): - error_count += 1 - - print(f"\n检查完成: {file_count} 个文件,{error_count} 个语法错误") - - if error_count > 0: - sys.exit(1) - else: - sys.exit(0) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/models/events/base.py b/models/events/base.py index 8d0ff83..29f46df 100644 --- a/models/events/base.py +++ b/models/events/base.py @@ -45,8 +45,10 @@ class OneBotEvent(ABC): time: int self_id: int + platform: str = "onebot" _bot: Optional["Bot"] = field(default=None, init=False) + @property @abstractmethod def post_type(self) -> str: diff --git a/performance_config_example.py b/performance_config_example.py deleted file mode 100644 index d6a50fa..0000000 --- a/performance_config_example.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -""" -性能分析配置示例 - -展示如何在项目中配置和使用性能分析功能。 -""" - -# 配置性能分析的使用方式 -PERFORMANCE_CONFIG = { - # 全局性能分析开关 - 'enabled': True, - - # 详细性能分析开关(使用 pyinstrument) - 'detailed': False, - - # 内存分析开关 - 'memory': False, - - # 性能监控阈值(秒) - 'threshold': 0.5, - - # 性能报告输出文件 - 'output_file': 'performance_report.html', - - # 要监控的核心组件列表 - 'monitored_components': [ - 'core.ws.WS', - 'core.managers.plugin_manager', - 'core.managers.browser_manager', - 'core.utils.executor.CodeExecutor', - 'core.handlers.event_handler', - ] -} - - -def get_performance_config(): - """ - 获取性能分析配置 - - Returns: - dict: 性能分析配置 - """ - import os - - # 从环境变量加载配置 - config = PERFORMANCE_CONFIG.copy() - - if os.environ.get('PERFORMANCE_PROFILE'): - config['detailed'] = os.environ['PERFORMANCE_PROFILE'] == '1' - - if os.environ.get('PERFORMANCE_MEMORY'): - config['memory'] = os.environ['PERFORMANCE_MEMORY'] == '1' - - if os.environ.get('PERFORMANCE_THRESHOLD'): - try: - config['threshold'] = float(os.environ['PERFORMANCE_THRESHOLD']) - except ValueError: - pass - - if os.environ.get('PERFORMANCE_OUTPUT'): - config['output_file'] = os.environ['PERFORMANCE_OUTPUT'] - - if os.environ.get('PERFORMANCE_STATS'): - config['enabled'] = os.environ['PERFORMANCE_STATS'] == '1' - - return config - - -if __name__ == "__main__": - # 打印当前配置 - print("当前性能分析配置:") - print("=" * 50) - config = get_performance_config() - for key, value in config.items(): - print(f"{key}: {value}") diff --git a/plugins/cross_platform.py b/plugins/cross_platform.py deleted file mode 100644 index 15ec0ac..0000000 --- a/plugins/cross_platform.py +++ /dev/null @@ -1,1015 +0,0 @@ -# -*- coding: utf-8 -*- -""" -跨平台消息互通插件 -功能: -- Discord 频道与 QQ 群之间的消息互通 -- 在消息中自动标注来源平台和子频道/群组 ID -- 支持 OneBot v11 协议和数据结构 -- 支持图片、视频等媒体消息 -- 支持合并转发消息 -""" -import asyncio -import html -import json -import os -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 - - -async def parse_forward_nodes(nodes: List[Dict[str, Any]]) -> tuple[str, List[dict]]: - """ - 解析 OneBot 合并转发消息节点 - - Args: - nodes: 合并转发消息节点列表 - - Returns: - 格式化后的消息内容和附件列表 - """ - content_parts = [] - attachments = [] - - for node in nodes: - if not isinstance(node, dict): - continue - - node_data = node.get("data", {}) - node_content = node_data.get("content", "") - - # 获取发送者信息 - sender_name = node_data.get("name", node_data.get("uin", "Unknown")) - - # 解析节点内容 - if isinstance(node_content, str): - # 检查是否是 [object Object] 格式(OneBot 协议的特殊格式) - if "[object Object]" in node_content: - # OneBot 协议中,合并转发消息的 content 可能是 [object Object],[object Object] - # 实际的消息内容在 nodes 中,直接使用节点作为消息内容 - content = f"[合并转发消息: {sender_name}]" - content_parts.append(f"**{sender_name}**:\n{content}") - elif '[CQ:' in node_content: - # CQ 码字符串格式 - content = parse_cq_code(node_content, attachments) - content_parts.append(f"**{sender_name}**:\n{content}") - else: - content = node_content - content_parts.append(f"**{sender_name}**:\n{content}") - elif isinstance(node_content, list): - # MessageSegment 列表格式 - content = parse_message_segments(node_content, attachments) - content_parts.append(f"**{sender_name}**:\n{content}") - - # 组合完整消息 - full_content = "\n\n".join(content_parts) if content_parts else "" - - return full_content, attachments - - -def parse_cq_code(cq_code: str, attachments: List[dict]) -> str: - """ - 解析 CQ 码字符串 - - Args: - cq_code: CQ 码字符串 - attachments: 附件列表(用于添加图片/视频 URL) - - Returns: - 解析后的文本内容 - """ - import re - - # 匹配 CQ 码 - cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' - matches = list(re.finditer(cq_pattern, cq_code)) - - if not matches: - return cq_code - - result = [] - last_end = 0 - - for match in matches: - if match.start() > last_end: - result.append(cq_code[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 == "text": - result.append(params.get("text", "")) - elif cq_type == "image": - file_url = params.get("url") or params.get("file") - if file_url: - file_name = params.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "image" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[图片: {file_name}]\n") - elif cq_type == "video": - file_url = params.get("url") or params.get("file") - if file_url: - file_name = params.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "video" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[视频: {file_name}]\n") - elif cq_type == "at": - qq_id = params.get("qq") - if qq_id == "all": - result.append("@所有人 ") - else: - result.append(f"@{qq_id} ") - elif cq_type == "face": - face_id = params.get("id", "") - result.append(f"[表情:{face_id}] ") - elif cq_type == "reply": - reply_id = params.get("id", "") - result.append(f"[回复:{reply_id}] ") - elif cq_type == "file": - file_url = params.get("file", "") - if file_url: - file_name = os.path.basename(str(file_url).split('?')[0]) or "file" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[文件: {file_name}]\n") - - last_end = match.end() - - if last_end < len(cq_code): - result.append(cq_code[last_end:]) - - return "".join(result) - - -def parse_message_segments(segments: List[Any], attachments: List[dict]) -> str: - """ - 解析 MessageSegment 列表 - - Args: - segments: MessageSegment 列表 - attachments: 附件列表(用于添加图片/视频 URL) - - Returns: - 解析后的文本内容 - """ - result = [] - - for seg in segments: - if isinstance(seg, str): - result.append(seg) - elif isinstance(seg, MessageSegment): - seg_type = seg.type - seg_data = seg.data - - if seg_type == "text": - result.append(seg_data.get("text", "")) - elif seg_type == "image": - file_url = seg_data.get("url") or seg_data.get("file") - if file_url: - file_name = seg_data.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "image" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[图片: {file_name}]\n") - elif seg_type == "video": - file_url = seg_data.get("url") or seg_data.get("file") - if file_url: - file_name = seg_data.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "video" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[视频: {file_name}]\n") - elif seg_type == "at": - qq_id = seg_data.get("qq") - if qq_id == "all": - result.append("@所有人 ") - else: - result.append(f"@{qq_id} ") - elif seg_type == "face": - face_id = seg_data.get("id", "") - result.append(f"[表情:{face_id}] ") - elif seg_type == "reply": - reply_id = seg_data.get("id", "") - result.append(f"[回复:{reply_id}] ") - elif seg_type == "file": - file_url = seg_data.get("file", "") - if file_url: - file_name = os.path.basename(str(file_url).split('?')[0]) or "file" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[文件: {file_name}]\n") - elif seg_type == "json": - # 尝试解析 JSON 数据 - json_data = seg_data.get("data", "") - try: - parsed = json.loads(json_data) - if isinstance(parsed, dict): - result.append(f"\n[JSON数据: {json_data[:100]}...]\n") - except: - result.append(f"\n[JSON数据]\n") - elif seg_type == "xml": - result.append(f"\n[XML数据]\n") - elif isinstance(seg, dict): - seg_type = seg.get("type") - seg_data = seg.get("data", {}) - - if seg_type == "text": - result.append(seg_data.get("text", "")) - elif seg_type == "image": - file_url = seg_data.get("url") or seg_data.get("file") - if file_url: - file_name = seg_data.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "image" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[图片: {file_name}]\n") - elif seg_type == "video": - file_url = seg_data.get("url") or seg_data.get("file") - if file_url: - file_name = seg_data.get("file", "") - if not file_name: - file_name = os.path.basename(str(file_url).split('?')[0]) or "video" - attachments.append({"url": str(file_url), "filename": file_name}) - result.append(f"\n[视频: {file_name}]\n") - elif seg_type == "at": - qq_id = seg_data.get("qq") - if qq_id == "all": - result.append("@所有人 ") - else: - result.append(f"@{qq_id} ") - - return "".join(result) - - -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"[PAW qq]" - return "" - - -async def format_discord_to_qq_content( - discord_username: str, - discord_discriminator: str, - content: str, - channel_id: int, - attachments: List[dict] = 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 - - # 提取图片 URL - image_list = [] - if attachments: - for att in attachments: - if isinstance(att, dict): - url = att.get("url", "") - if url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): - image_list.append(url) - else: - image_list.append(str(att)) - - return full_message, image_list - - -async def format_qq_to_discord_content( - qq_nickname: str, - qq_user_id: int, - group_name: str, - group_id: int, - content: str, - attachments: List[dict] = None -) -> tuple[str, List[dict], dict]: - """ - 将 QQ 消息格式化为 Discord 消息格式(Embed 卡片) - - Args: - qq_nickname: QQ 昵称 - qq_user_id: QQ 用户 ID - group_name: 群名称 - group_id: QQ 群 ID - content: 消息内容 - attachments: 附件列表 - - Returns: - 格式化后的消息内容、附件列表和 Embed 字典 - """ - platform_info = get_platform_info("qq", group_id) - - # 构建 Embed 卡片 - embed = { - "type": "rich", - "color": 0x5865F2, # Discord 蓝色 - "author": { - "name": f"{platform_info} {qq_nickname}", - "icon_url": f"https://q1.qlogo.cn/g?b=qq&nk={qq_user_id}&s=640" - }, - "description": content if content else "", - "timestamp": None, - "footer": { - "text": f"来自 QQPAW" - } - } - - # 如果有附件,添加到 description - if attachments: - image_urls = [] - other_urls = [] - for att in attachments: - if att.get("url", "").lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): - image_urls.append(att.get("url")) - else: - other_urls.append(att.get("url")) - - if image_urls: - embed["description"] = f"{content}\n\n{chr(10).join(image_urls[:3])}" if content else chr(10).join(image_urls[:3]) - if len(image_urls) > 3: - embed["description"] += f"\n...还有 {len(image_urls) - 3} 张图片" - - # 附加文件列表 - if other_urls: - file_list = "\n".join([f"📄 {os.path.basename(u.split('?')[0])}" for u in other_urls[:5]]) - embed["description"] += f"\n\n**附加文件:**\n{file_list}" - if len(other_urls) > 5: - embed["description"] += f"\n...还有 {len(other_urls) - 5} 个文件" - - # 对于合并转发消息,content 为空,只发送 embed - # 对于普通消息,content 也为空,只发送 embed - return "", attachments or [], embed - - -async def send_to_discord(channel_id: int, content: str, attachments: List[dict] = None, embed: dict = None): - """ - 发送消息到 Discord 频道 - - 通过 Redis 发布消息,由 Discord 适配器接收并发送 - 这样可以避免跨模块导入实例的问题 - - Args: - channel_id: Discord 频道 ID - content: 消息内容 - attachments: 附件列表,每个元素为 {"url": str, "filename": str} - embed: Discord Embed 字典 - """ - try: - publish_data = { - "type": "send_message", - "channel_id": channel_id, - "content": content, - "attachments": attachments or [], - "embed": embed - } - 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[dict] = None): - """ - 发送消息到 QQ 群 - - Args: - group_id: QQ 群 ID - content: 消息内容 - attachments: 附件列表,每个元素为 {"url": str, "filename": str} - """ - 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: - if isinstance(attachment, dict): - attachment_url = attachment.get("url", "") - else: - attachment_url = str(attachment) - full_message.append(MessageSegment.image(attachment_url, 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[dict] = 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[dict] = 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, embed = 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, embed) - - 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[dict] = None, - embed: dict = None -): - """ - 处理 Discord 消息并转发 - - Args: - username: Discord 用户名 - discriminator: Discord discriminator - content: 消息内容 - channel_id: Discord 频道 ID - attachments: 附件列表 - embed: Discord Embed 字典 - """ - 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[dict] = 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): - # 检查是否是合并转发消息 - 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"] - # 将 MessageSegment 转换为字典格式 - 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("file", "") - 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({"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("file", "") - 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({"url": file_url, "filename": file_name}) - 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 - attachment_url = line - # 尝试从 URL 提取文件名 - filename = os.path.basename(attachment_url.split('?')[0]) or "attachment" - attachment_item = {"url": attachment_url, "filename": filename} - if attachment_item not in attachments: - attachments.append(attachment_item) - 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") - file_name = segment.data.get("file", "") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "image" - attachment_item = {"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("file", "") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video" - attachment_item = {"url": str(file_url), "filename": file_name} - if attachment_item not in attachments: - attachments.append(attachment_item) - - # 获取用户信息 - 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, - embed=None - ) - - -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", []), - embed=message_data.get("embed") - ) - - 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/plugins/discord-cross/__init__.py b/plugins/discord-cross/__init__.py new file mode 100644 index 0000000..c132d9c --- /dev/null +++ b/plugins/discord-cross/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件入口 +""" +import asyncio +from core.utils.logger import logger +from .config import config +from .subscription import start_cross_platform_subscription, stop_cross_platform_subscription +from .handlers import * + +# 插件加载时自动启动和加载配置 +try: + asyncio.create_task(config.reload()) +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}") + +def cleanup(): + """清理资源""" + asyncio.create_task(stop_cross_platform_subscription()) \ No newline at end of file diff --git a/plugins/discord-cross/config.py b/plugins/discord-cross/config.py new file mode 100644 index 0000000..739fc33 --- /dev/null +++ b/plugins/discord-cross/config.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件配置模块 +""" +import os +from typing import Dict, Any +from core.utils.logger import logger + +class CrossPlatformConfig: + def __init__(self): + self.CROSS_PLATFORM_MAP: Dict[int, Dict[str, Any]] = {} + self.CROSS_PLATFORM_CHANNEL = "neobot_cross_platform" + self.ENABLE_CROSS_PLATFORM = True + + # DeepSeek API 配置 + self.DEEPSEEK_API_KEY = "sk-Cn4BeHyTHDPRKuDadLy6dUnjSSHxrz5wQa54ZFAdQovXguLD" + self.DEEPSEEK_API_URL = "https://api.gptgod.online/v1/chat/completions" + self.DEEPSEEK_MODEL = "gemini-3-flash-preview" + + # 是否启用翻译功能 + self.ENABLE_TRANSLATION = True + + async def reload(self): + """重新加载配置""" + try: + 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_data = tomllib.load(f) + + cross_platform_config = config_data.get("cross_platform", {}) + self.ENABLE_CROSS_PLATFORM = cross_platform_config.get("enabled", True) + + # 重新加载映射配置 + mappings = cross_platform_config.get("mappings", {}) + self.CROSS_PLATFORM_MAP.clear() + + 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]) + self.CROSS_PLATFORM_MAP[discord_id] = { + "qq_group_id": int(value.get("qq_group_id", 0)), + "name": value.get("name", "") + } + except (ValueError, AttributeError): + continue + + logger.success(f"[CrossPlatform] 配置已重新加载: {len(self.CROSS_PLATFORM_MAP)} 个映射") + + except Exception as e: + logger.error(f"[CrossPlatform] 重新加载配置失败: {e}") + +config = CrossPlatformConfig() diff --git a/plugins/discord-cross/handlers.py b/plugins/discord-cross/handlers.py new file mode 100644 index 0000000..5441b5b --- /dev/null +++ b/plugins/discord-cross/handlers.py @@ -0,0 +1,229 @@ +# -*- 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}") + 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 == "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 + + 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 = "" + attachments = [] + + 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) + else: + content = event.raw_message or "" + + content = content.strip() + + 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, + 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/plugins/discord-cross/parser.py b/plugins/discord-cross/parser.py new file mode 100644 index 0000000..08a579e --- /dev/null +++ b/plugins/discord-cross/parser.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件解析器模块 +""" +import os +import json +from typing import Dict, List, Any +from models.message import MessageSegment +from .config import config + +async def parse_forward_nodes(nodes: List[Dict[str, Any]]) -> tuple[str, List[dict]]: + """解析 OneBot 合并转发消息节点""" + content_parts = [] + attachments = [] + + for node in nodes: + if not isinstance(node, dict): + continue + + node_data = node.get("data", {}) + node_content = node_data.get("content", "") + + sender_name = node_data.get("name", node_data.get("uin", "Unknown")) + + if isinstance(node_content, str): + if "[object Object]" in node_content: + content = f"[合并转发消息: {sender_name}]" + content_parts.append(f"**{sender_name}**:\n{content}") + elif '[CQ:' in node_content: + content = parse_cq_code(node_content, attachments) + content_parts.append(f"**{sender_name}**:\n{content}") + else: + content = node_content + content_parts.append(f"**{sender_name}**:\n{content}") + elif isinstance(node_content, list): + content = parse_message_segments(node_content, attachments) + content_parts.append(f"**{sender_name}**:\n{content}") + + full_content = "\n\n".join(content_parts) if content_parts else "" + return full_content, attachments + +def parse_cq_code(cq_code: str, attachments: List[dict]) -> str: + """解析 CQ 码字符串""" + import re + + cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' + matches = list(re.finditer(cq_pattern, cq_code)) + + if not matches: + return cq_code + + result = [] + last_end = 0 + + for match in matches: + if match.start() > last_end: + result.append(cq_code[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 == "text": + result.append(params.get("text", "")) + elif cq_type == "image": + file_url = params.get("url") or params.get("file") + if file_url: + file_name = params.get("file", "") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "image" + attachments.append({"type": "image", "url": str(file_url), "filename": file_name}) + result.append(f"\n[图片: {file_name}]\n") + elif cq_type == "video": + file_url = params.get("url") or params.get("file") + if file_url: + file_name = params.get("file", "") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "video" + attachments.append({"type": "video", "url": str(file_url), "filename": file_name}) + result.append(f"\n[视频: {file_name}]\n") + elif cq_type == "record": + file_url = params.get("url") or params.get("file") + if file_url: + file_name = params.get("file", "") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "record" + attachments.append({"type": "record", "url": str(file_url), "filename": file_name}) + result.append(f"\n[语音: {file_name}]\n") + elif cq_type == "at": + qq_id = params.get("qq") + if qq_id == "all": + result.append("@所有人 ") + else: + result.append(f"@{qq_id} ") + elif cq_type == "face": + face_id = params.get("id", "") + result.append(f"[表情:{face_id}] ") + elif cq_type == "reply": + reply_id = params.get("id", "") + result.append(f"[回复:{reply_id}] ") + elif cq_type == "file": + file_url = params.get("file", "") + if file_url: + file_name = os.path.basename(str(file_url).split('?')[0]) or "file" + attachments.append({"type": "file", "url": str(file_url), "filename": file_name}) + result.append(f"\n[文件: {file_name}]\n") + + last_end = match.end() + + if last_end < len(cq_code): + result.append(cq_code[last_end:]) + + return "".join(result) + +def parse_message_segments(segments: List[Any], attachments: List[dict]) -> str: + """解析 MessageSegment 列表""" + result = [] + + for seg in segments: + if isinstance(seg, str): + result.append(seg) + elif isinstance(seg, MessageSegment): + seg_type = seg.type + seg_data = seg.data + + if seg_type == "text": + result.append(seg_data.get("text", "")) + elif seg_type == "image": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "image" + attachments.append({"type": "image", "url": str(file_url), "filename": file_name}) + result.append(f"\n[图片: {file_name}]\n") + elif seg_type == "video": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "video" + attachments.append({"type": "video", "url": str(file_url), "filename": file_name}) + result.append(f"\n[视频: {file_name}]\n") + elif seg_type == "record": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "record" + attachments.append({"type": "record", "url": str(file_url), "filename": file_name}) + result.append(f"\n[语音: {file_name}]\n") + elif seg_type == "at": + qq_id = seg_data.get("qq") + if qq_id == "all": + result.append("@所有人 ") + else: + result.append(f"@{qq_id} ") + elif seg_type == "face": + face_id = seg_data.get("id", "") + result.append(f"[表情:{face_id}] ") + elif seg_type == "reply": + reply_id = seg_data.get("id", "") + result.append(f"[回复:{reply_id}] ") + elif seg_type == "file": + file_url = seg_data.get("file", "") + if file_url: + file_name = os.path.basename(str(file_url).split('?')[0]) or "file" + attachments.append({"type": "file", "url": str(file_url), "filename": file_name}) + result.append(f"\n[文件: {file_name}]\n") + elif seg_type == "json": + json_data = seg_data.get("data", "") + try: + parsed = json.loads(json_data) + if isinstance(parsed, dict): + result.append(f"\n[JSON数据: {json_data[:100]}...]\n") + except: + result.append(f"\n[JSON数据]\n") + elif seg_type == "xml": + result.append(f"\n[XML数据]\n") + elif isinstance(seg, dict): + seg_type = seg.get("type") + seg_data = seg.get("data", {}) + + if seg_type == "text": + result.append(seg_data.get("text", "")) + elif seg_type == "image": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "image" + attachments.append({"type": "image", "url": str(file_url), "filename": file_name}) + result.append(f"\n[图片: {file_name}]\n") + elif seg_type == "video": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "video" + attachments.append({"type": "video", "url": str(file_url), "filename": file_name}) + result.append(f"\n[视频: {file_name}]\n") + elif seg_type == "record": + file_url = seg_data.get("url") or seg_data.get("file") + if file_url: + file_name = seg_data.get("filename") + if not file_name: + file_name = os.path.basename(str(file_url).split('?')[0]) or "record" + attachments.append({"type": "record", "url": str(file_url), "filename": file_name}) + result.append(f"\n[语音: {file_name}]\n") + elif seg_type == "at": + qq_id = seg_data.get("qq") + if qq_id == "all": + result.append("@所有人 ") + else: + result.append(f"@{qq_id} ") + + return "".join(result) + +def get_platform_info(platform: str, identifier: Any) -> str: + """获取平台信息字符串""" + if platform == "discord": + channel_id = int(identifier) + if channel_id in config.CROSS_PLATFORM_MAP: + group_info = config.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"[PAW qq]" + return "" + +async def format_discord_to_qq_content( + discord_username: str, + discord_discriminator: str, + content: str, + channel_id: int, + attachments: List[dict] = None +) -> tuple[str, List[dict]]: + """将 Discord 消息格式化为 QQ 消息格式""" + platform_info = get_platform_info("discord", channel_id) + + message_header = f"{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 + + processed_attachments = [] + if attachments: + for att in attachments: + if isinstance(att, dict): + url = att.get("url", "") + filename = att.get("filename", "").lower() + att_type = att.get("type", "") + + if att_type == "image" or filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + processed_attachments.append({"type": "image", "url": url}) + elif att_type == "record" or filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')): + processed_attachments.append({"type": "record", "url": url}) + elif att_type == "video" or filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')): + processed_attachments.append({"type": "video", "url": url}) + else: + url = str(att) + if url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + processed_attachments.append({"type": "image", "url": url}) + elif url.lower().endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')): + processed_attachments.append({"type": "record", "url": url}) + elif url.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')): + processed_attachments.append({"type": "video", "url": url}) + + return full_message, processed_attachments + +async def format_qq_to_discord_content( + qq_nickname: str, + qq_user_id: int, + group_name: str, + group_id: int, + content: str, + attachments: List[dict] = None +) -> tuple[str, List[dict], dict]: + """将 QQ 消息格式化为 Discord 消息格式(Embed 卡片)""" + platform_info = get_platform_info("qq", group_id) + + embed = { + "type": "rich", + "color": 0x5865F2, + "author": { + "name": f"{qq_nickname}", + "icon_url": f"https://q1.qlogo.cn/g?b=qq&nk={qq_user_id}&s=640" + }, + "description": content if content else "", + "timestamp": None, + "footer": { + "text": f"来自 QQ" + } + } + + if attachments: + image_urls = [] + voice_urls = [] + video_urls = [] + other_urls = [] + + filtered_attachments = [] + + for att in attachments: + url = att.get("url", "") + filename = att.get("filename", "").lower() + att_type = att.get("type", "") + + if att_type == "image" or filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): + image_urls.append(url) + if len(image_urls) > 1: + filtered_attachments.append(att) + elif att_type == "record" or filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')): + voice_urls.append(url) + other_urls.append(url) + filtered_attachments.append(att) + elif att_type == "video" or filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')): + video_urls.append(url) + other_urls.append(url) + filtered_attachments.append(att) + else: + other_urls.append(url) + filtered_attachments.append(att) + + attachments = filtered_attachments + embed["description"] = content if content else "" + + if image_urls: + embed["image"] = {"url": image_urls[0]} + + if voice_urls: + voice_filenames = [att.get("filename", "voice") for att in attachments if att.get("url") in voice_urls] + voice_list = "\n".join([f"🎤 {fname}" for fname in voice_filenames[:5]]) + embed["description"] += f"\n\n**语音消息:**\n{voice_list}" + if len(voice_urls) > 5: + embed["description"] += f"\n...还有 {len(voice_urls) - 5} 条语音" + + if video_urls: + video_filenames = [att.get("filename", "video") for att in attachments if att.get("url") in video_urls] + video_list = "\n".join([f"🎬 {fname}" for fname in video_filenames[:5]]) + embed["description"] += f"\n\n**视频文件:**\n{video_list}" + if len(video_urls) > 5: + embed["description"] += f"\n...还有 {len(video_urls) - 5} 个视频" + + non_media_other_urls = [u for u in other_urls if u not in voice_urls and u not in video_urls] + if non_media_other_urls: + file_filenames = [att.get("filename", "file") for att in attachments if att.get("url") in non_media_other_urls] + file_list = "\n".join([f"📄 {fname}" for fname in file_filenames[:5]]) + embed["description"] += f"\n\n**附加文件:**\n{file_list}" + if len(non_media_other_urls) > 5: + embed["description"] += f"\n...还有 {len(non_media_other_urls) - 5} 个文件" + + return "", attachments or [], embed \ No newline at end of file diff --git a/plugins/discord-cross/sender.py b/plugins/discord-cross/sender.py new file mode 100644 index 0000000..1337086 --- /dev/null +++ b/plugins/discord-cross/sender.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件发送器模块 +""" +import json +from typing import List +from core.utils.logger import logger +from core.managers.redis_manager import redis_manager +from .config import config +from .translator import translate_with_deepseek +from .parser import format_discord_to_qq_content, format_qq_to_discord_content + +async def send_to_discord(channel_id: int, content: str, attachments: List[dict] = None, embed: dict = None): + """发送消息到 Discord 频道""" + try: + publish_data = { + "type": "send_message", + "channel_id": channel_id, + "content": content, + "attachments": attachments or [], + "embed": embed + } + 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[dict] = None): + """发送消息到 QQ 群""" + try: + from core.managers.bot_manager import bot_manager + from models.message import MessageSegment + + 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 + + if attachments: + full_message = [] + if content: + full_message.append(MessageSegment.text(content)) + for attachment in attachments: + if isinstance(attachment, dict): + att_type = attachment.get("type", "image") + attachment_url = attachment.get("url", "") + + if att_type == "image": + full_message.append(MessageSegment.image(attachment_url, cache=True, proxy=True, timeout=30)) + elif att_type == "record": + full_message.append(MessageSegment.record(attachment_url, cache=True, proxy=True, timeout=30)) + elif att_type == "video": + full_message.append(MessageSegment.video(attachment_url)) + else: + attachment_url = str(attachment) + if attachment_url.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')): + full_message.append(MessageSegment.video(attachment_url)) + elif attachment_url.lower().endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')): + full_message.append(MessageSegment.record(attachment_url, cache=True, proxy=True, timeout=30)) + else: + full_message.append(MessageSegment.image(attachment_url, 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[dict] = None +): + """将 Discord 消息转发到所有映射的 QQ 群""" + if channel_id not in config.CROSS_PLATFORM_MAP: + logger.warning(f"[CrossPlatform] 未找到 Discord 频道 {channel_id} 的映射配置") + return + + group_info = config.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 + ) + + if formatted_content: + translated_content = await translate_with_deepseek(formatted_content, "zh-CN", channel_id, "en2zh") + if translated_content != formatted_content: + formatted_content = f"{formatted_content}\n\n━━━━━ 翻译 ━━━━━\n{translated_content}" + + 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[dict] = None +): + """将 QQ 消息转发到所有映射的 Discord 频道""" + target_channels = [] + for discord_channel_id, info in config.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, embed = await format_qq_to_discord_content( + qq_nickname, + qq_user_id, + group_name, + group_id, + content, + attachments + ) + + if embed and embed.get("description"): + original_text = embed["description"] + translated_text = await translate_with_deepseek(original_text, "en", group_id, "zh2en") + if translated_text != original_text: + embed["description"] = f"{original_text}\n\n**Translation:**\n{translated_text}" + + for channel_id in target_channels: + await send_to_discord(channel_id, formatted_content, image_list, embed) + + logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}") + +async def publish_to_redis(platform: str, data: dict): + """通过 Redis 发布跨平台消息""" + try: + if redis_manager.redis: + publish_data = { + "platform": platform, + "data": data, + "timestamp": int(__import__('time').time()) + } + await redis_manager.redis.publish(config.CROSS_PLATFORM_CHANNEL, json.dumps(publish_data)) + logger.debug(f"[CrossPlatform] 已通过 Redis 发布消息: platform={platform}") + except Exception as e: + logger.error(f"[CrossPlatform] Redis 发布失败: {e}") \ No newline at end of file diff --git a/plugins/discord-cross/subscription.py b/plugins/discord-cross/subscription.py new file mode 100644 index 0000000..32a3e5a --- /dev/null +++ b/plugins/discord-cross/subscription.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件订阅模块 +""" +import json +import asyncio +from core.utils.logger import logger +from core.managers.redis_manager import redis_manager +from .config import config +from .sender import forward_discord_to_qq, forward_qq_to_discord + +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(config.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": + 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": + 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", []), + embed=message_data.get("embed") + ) + + 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 config.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] 跨平台消息订阅已停止") \ No newline at end of file diff --git a/plugins/discord-cross/translator.py b/plugins/discord-cross/translator.py new file mode 100644 index 0000000..efc7233 --- /dev/null +++ b/plugins/discord-cross/translator.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +""" +跨平台消息互通插件翻译模块 +""" +from typing import Dict, List +from core.utils.logger import logger +from .config import config + +# 翻译上下文缓存(每个通道15条消息) +TRANSLATION_CONTEXT_CACHE: Dict[str, List[Dict[str, str]]] = {} +MAX_CONTEXT_MESSAGES = 15 + +def get_translation_context(channel_id: int, direction: str) -> List[Dict[str, str]]: + """获取翻译上下文缓存""" + cache_key = f"{channel_id}_{direction}" + return TRANSLATION_CONTEXT_CACHE.get(cache_key, []) + +def add_translation_context(channel_id: int, direction: str, original: str, translated: str): + """添加翻译到上下文缓存""" + cache_key = f"{channel_id}_{direction}" + if cache_key not in TRANSLATION_CONTEXT_CACHE: + TRANSLATION_CONTEXT_CACHE[cache_key] = [] + + TRANSLATION_CONTEXT_CACHE[cache_key].append({ + "original": original, + "translated": translated + }) + + if len(TRANSLATION_CONTEXT_CACHE[cache_key]) > MAX_CONTEXT_MESSAGES: + TRANSLATION_CONTEXT_CACHE[cache_key] = TRANSLATION_CONTEXT_CACHE[cache_key][-MAX_CONTEXT_MESSAGES:] + +async def translate_with_deepseek( + text: str, + target_lang: str = "zh-CN", + channel_id: int = 0, + direction: str = "en2zh" +) -> str: + """使用 DeepSeek API 翻译文本""" + if not config.ENABLE_TRANSLATION or not text.strip(): + return text + + if config.DEEPSEEK_API_KEY == "your-deepseek-api-key-here": + logger.warning("[CrossPlatform] DeepSeek API 密钥未配置,跳过翻译") + return text + + lang_name = "中文" if target_lang == "zh-CN" else "英文" + + messages = [] + context_ref = "" + if channel_id > 0: + context = get_translation_context(channel_id, direction) + if context: + context_ref = "\n\n参考之前的翻译:\n" + for i, ctx in enumerate(context[-5:], 1): + context_ref += f"{i}. 原文: {ctx['original'][:100]}\n 译文: {ctx['translated'][:100]}\n" + + system_prompt = f"""你是一个专业的翻译助手。请将以下文本翻译成{lang_name}。 +只返回翻译后的文本,不要添加任何解释、注释或其他内容。避免翻译出仇视言论以及违反中国大陆相关法律法规的内容。如果有,请在翻译后有敏感的词语中把文本替换成井号(#) +保持原文的语气和格式。如果文本已经是目标语言,直接返回原文。{context_ref}""" + + messages.append({"role": "user", "content": text}) + + try: + from openai import AsyncOpenAI + + client = AsyncOpenAI( + api_key=config.DEEPSEEK_API_KEY, + base_url=config.DEEPSEEK_API_URL.replace("/chat/completions", "") + ) + + response = await client.chat.completions.create( + model=config.DEEPSEEK_MODEL, + messages=[{"role": "system", "content": system_prompt}] + messages, + temperature=0.3, + max_tokens=4000 + ) + + translated_text = response.choices[0].message.content + if translated_text: + translated_text = translated_text.strip() + logger.info(f"[CrossPlatform] 翻译成功: {text[:50]}... -> {translated_text[:50]}...") + + if channel_id > 0: + add_translation_context(channel_id, direction, text, translated_text) + + return translated_text + else: + logger.warning("[CrossPlatform] DeepSeek 返回空翻译结果") + return text + + except ImportError: + logger.warning("[CrossPlatform] openai 库未安装,尝试使用同步请求") + return await translate_with_deepseek_sync(text, target_lang, channel_id, direction) + except Exception as e: + logger.error(f"[CrossPlatform] 翻译失败: {e}") + return text + +async def translate_with_deepseek_sync( + text: str, + target_lang: str = "zh-CN", + channel_id: int = 0, + direction: str = "en2zh" +) -> str: + """使用同步请求的 DeepSeek 翻译(备用方案)""" + if not config.ENABLE_TRANSLATION or not text.strip(): + return text + + if config.DEEPSEEK_API_KEY == "your-deepseek-api-key-here": + return text + + lang_name = "中文" if target_lang == "zh-CN" else "英文" + + context_ref = "" + if channel_id > 0: + context = get_translation_context(channel_id, direction) + if context: + context_ref = "\n\n参考之前的翻译:\n" + for i, ctx in enumerate(context[-5:], 1): + context_ref += f"{i}. 原文: {ctx['original'][:100]}\n 译文: {ctx['translated'][:100]}\n" + + system_prompt = f"""你是一个专业的翻译助手。请将以下文本翻译成{lang_name}。 +只返回翻译后的文本,不要添加任何解释、注释或其他内容。避免翻译出仇视言论以及违反中国大陆相关法律法规的内容。如果有,请在翻译后有敏感的词语中把文本替换成井号(#) +保持原文的语气和格式。如果文本已经是目标语言,直接返回原文。{context_ref}""" + + messages = [{"role": "user", "content": text}] + + try: + from openai import OpenAI + + client = OpenAI( + api_key=config.DEEPSEEK_API_KEY, + base_url=config.DEEPSEEK_API_URL.replace("/chat/completions", "") + ) + + response = client.chat.completions.create( + model=config.DEEPSEEK_MODEL, + messages=[{"role": "system", "content": system_prompt}] + messages, + temperature=0.3, + max_tokens=4000 + ) + + translated_text = response.choices[0].message.content + if translated_text: + translated_text = translated_text.strip() + if channel_id > 0: + add_translation_context(channel_id, direction, text, translated_text) + return translated_text + return text + + except Exception as e: + logger.error(f"[CrossPlatform] 同步翻译失败: {e}") + return text diff --git a/plugins/osu!_plugin/__init__.py b/plugins/osu!_plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/osu!_plugin/test.py b/plugins/osu!_plugin/test.py new file mode 100644 index 0000000..a40f1e1 --- /dev/null +++ b/plugins/osu!_plugin/test.py @@ -0,0 +1,11 @@ +from ossapi import Ossapi + +# 初始化客户端(替换为自己的client_id和client_secret) +api = Ossapi("49746", "3sLQQC92twXgETwkJwixZWs5Chvhpo1HHQbYklLN") + +# 根据用户名查询用户信息 +print(api.user("[PAW]K2CRO4")) +# 根据用户ID查询osu模式下的用户信息 +print(api.user(12092800, mode="osu").username) +# 查询指定谱面的ID +print(api.beatmap(221777).id) \ No newline at end of file diff --git a/profile_main.py b/profile_main.py deleted file mode 100644 index 34d55bc..0000000 --- a/profile_main.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -""" -性能分析入口文件 - -用于启动带有性能分析功能的应用程序。 - -使用方法: - python profile_main.py [options] - -选项: - -h, --help 显示帮助信息 - --profile, -p 启用详细性能分析(使用 pyinstrument) - --memory, -m 启用内存使用分析 - --output, -o FILE 性能分析报告输出文件(HTML格式) - --threshold, -t SEC 设置性能监控阈值(秒) - --stats, -s 在程序结束时输出性能统计报告 -""" - -import sys -import argparse -import os - -# 将项目根目录添加到 sys.path -ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, ROOT_DIR) - -# 解析命令行参数 -parser = argparse.ArgumentParser(description='性能分析入口文件') -parser.add_argument('--profile', '-p', action='store_true', help='启用详细性能分析(使用 pyinstrument)') -parser.add_argument('--memory', '-m', action='store_true', help='启用内存使用分析') -parser.add_argument('--output', '-o', type=str, default='performance_report.html', help='性能分析报告输出文件(HTML格式)') -parser.add_argument('--threshold', '-t', type=float, default=0.5, help='设置性能监控阈值(秒)') -parser.add_argument('--stats', '-s', action='store_true', help='在程序结束时输出性能统计报告') - -args = parser.parse_args() - -# 设置全局性能分析配置 -os.environ['PERFORMANCE_PROFILE'] = '1' if args.profile else '0' -os.environ['PERFORMANCE_MEMORY'] = '1' if args.memory else '0' -os.environ['PERFORMANCE_OUTPUT'] = args.output -os.environ['PERFORMANCE_THRESHOLD'] = str(args.threshold) -os.environ['PERFORMANCE_STATS'] = '1' if args.stats else '0' - -# 导入并运行主程序 -from main import main -import asyncio - -async def main_with_profile(): - """ - 带有性能分析的主函数入口 - """ - if args.profile: - # 使用 pyinstrument 进行详细性能分析 - from pyinstrument import Profiler - from pyinstrument.renderers import HTMLRenderer - - profiler = Profiler() - profiler.start() - - try: - await main() - finally: - profiler.stop() - - # 输出分析结果到控制台 - print("\n" + "=" * 80) - print("性能分析结果") - print("=" * 80) - print(profiler.print()) - - # 保存HTML报告 - try: - html = profiler.render(HTMLRenderer()) - with open(args.output, 'w', encoding='utf-8') as f: - f.write(html) - print(f"\n性能分析报告已保存到: {args.output}") - except Exception as e: - print(f"\n保存性能分析报告失败: {e}") - else: - # 不使用详细分析,直接运行 - await main() - -if __name__ == "__main__": - try: - asyncio.run(main_with_profile()) - finally: - # 输出性能统计报告 - if args.stats: - from core.utils.performance import performance_stats - print("\n" + "=" * 80) - print("性能统计报告") - print("=" * 80) - print(performance_stats.report()) diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index a635c5c..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -pythonpath = . diff --git a/setup_mypyc.py b/setup_mypyc.py deleted file mode 100644 index ba8c403..0000000 --- a/setup_mypyc.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -Mypyc 编译脚本 - -用于将核心 Python 模块编译为 C 扩展,以提升性能。 -使用方法: - python setup_mypyc.py build_ext --inplace - -注意: - 1. 需要安装 C 编译器 (Windows 上需要 Visual Studio Build Tools, Linux 上需要 GCC)。 - 2. 编译后的文件 (.pyd 或 .so) 是平台相关的,不能跨平台复制。 - 3. 建议在部署的目标环境 (Linux) 上运行此脚本。 -""" -import os -import sys -import subprocess - -# 基础模块列表 -# 注意:Mypyc 对动态特性支持有限,只选择计算密集或类型明确的模块 -modules = [ - # 工具模块 - 'core/utils/json_utils.py', # JSON 处理 - 'core/utils/executor.py', # 代码执行引擎 - 'core/utils/singleton.py', # 单例模式基类 - 'core/utils/exceptions.py', # 自定义异常 - 'core/utils/logger.py', # 日志模块 - - # 核心管理模块 - 'core/managers/command_manager.py', # 指令匹配和分发 - 'core/managers/permission_manager.py', # 权限管理(包含管理员管理功能) - 'core/managers/plugin_manager.py', # 插件管理器 - - # 核心基础模块 - 'core/ws.py', # WebSocket 核心 - 'core/bot.py', # Bot 核心抽象 - 'core/config_loader.py', # 配置加载 - 'core/config_models.py', # 配置模型 - 'core/permission.py', # 权限枚举 - - # API 基础模块 - 'core/api/base.py', # API 基础类 - - # 数据模型(适合编译的高频使用数据类) - 'models/message.py', # 消息段模型 - 'models/sender.py', # 发送者模型 - 'models/objects.py', # API 响应数据模型 -] - -# 注意:事件模型文件暂时不编译,因为它们与 mypyc 存在兼容性问题 -# mypyc 对某些数据类特性和继承结构的支持有限,会导致运行时错误 -# event_models = glob.glob('models/events/*.py') -# event_models = [m for m in event_models if not m.endswith('__init__.py')] -# modules.extend(event_models) - -# 确保文件存在 -valid_modules = [] -for m in modules: - if os.path.exists(m): - valid_modules.append(m) - else: - print(f"Warning: Module {m} not found, skipping.") - -if not valid_modules: - print("No valid modules found to compile.") - sys.exit(1) - -print(f"Compiling the following modules with mypyc: {valid_modules}") - -# 使用 mypyc 命令行工具单独编译每个模块,确保位置正确 -success_count = 0 -for module_path in valid_modules: - print(f"\nCompiling {module_path}...") - try: - # 直接调用 mypyc 命令行工具 - result = subprocess.run( - [sys.executable, '-m', 'mypyc', module_path], - capture_output=True, - text=True, - check=True - ) - - # 验证编译产物是否在正确位置 - module_name = module_path.replace('.py', '') - pyd_path = module_name + '.cp314-win_amd64.pyd' - mypyc_path = module_name + '__mypyc.cp314-win_amd64.pyd' - - if os.path.exists(pyd_path): - print(f" ✓ Compiled successfully: {pyd_path}") - success_count += 1 - else: - # 检查 build 目录中是否有编译产物 - build_pyd_path = os.path.join('build', 'lib.win-amd64-cpython-314', pyd_path) - if os.path.exists(build_pyd_path): - # 如果在 build 目录中,复制到正确位置 - os.makedirs(os.path.dirname(pyd_path), exist_ok=True) - import shutil - shutil.copy2(build_pyd_path, pyd_path) - shutil.copy2(os.path.join('build', 'lib.win-amd64-cpython-314', mypyc_path), mypyc_path) - print(f" ✓ Compiled successfully (copied from build directory): {pyd_path}") - success_count += 1 - else: - print(" ✗ Compiled but cannot find pyd file") - print(f" Build output:\n{result.stdout[:500]}...") - except subprocess.CalledProcessError as e: - print(f" ✗ Compilation failed with exit code {e.returncode}") - print(f" Error:\n{e.stderr[:500]}...") - except Exception as e: - print(f" ✗ Unexpected error: {e}") - -print("\n--- Compilation Summary ---") -print(f"Total modules: {len(valid_modules)}") -print(f"Successfully compiled: {success_count}") -print(f"Failed: {len(valid_modules) - success_count}") - -if success_count == 0: - print("No modules were compiled successfully. Exiting with error.") - sys.exit(1) - diff --git a/test_image_fix.py b/test_image_fix.py deleted file mode 100644 index 7404d2e..0000000 --- a/test_image_fix.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/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所有测试完成!") diff --git a/test_performance_simple.py b/test_performance_simple.py deleted file mode 100644 index 8c2687a..0000000 --- a/test_performance_simple.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -""" -简单的性能分析功能测试脚本 -""" - -import asyncio -import time -from core.utils.performance import ( - timeit, - profile, - performance_stats -) - - -print("=" * 80) -print("性能分析功能测试") -print("=" * 80) - -# 重置全局性能统计 -performance_stats.reset() - -# 测试1: 同步函数的时间测量 -@timeit -def sync_test(): - """同步测试函数""" - time.sleep(0.1) - return "sync done" - -# 测试2: 异步函数的时间测量 -@timeit -async def async_test(): - """异步测试函数""" - await asyncio.sleep(0.1) - return "async done" - -# 异步主函数 -async def main(): - # 同步函数测试 - print("执行同步函数...") - sync_result = sync_test() - print(f"同步函数结果: {sync_result}") - - # 异步函数测试 - print("\n执行异步函数...") - async_result = await async_test() - print(f"异步函数结果: {async_result}") - - # 测试3: 详细性能分析 - print("\n2. 测试性能分析上下文管理器:") - print("=" * 80) - - with profile(enabled=False): # 禁用实际分析以避免输出太多 - await asyncio.sleep(0.05) - print("性能分析上下文管理器测试完成") - - # 测试4: 性能统计报告 - print("\n3. 测试性能统计报告:") - print("=" * 80) - - # 执行多次函数调用 - for _ in range(3): - sync_test() - await async_test() - - # 生成并打印性能报告 - print("\n性能统计报告:") - print(performance_stats.report()) - - -# 执行测试 -print("\n1. 测试时间测量装饰器:") -print("=" * 80) - -# 使用 asyncio.run() 执行异步主函数 -asyncio.run(main()) - -print("\n" + "=" * 80) -print("所有测试完成!") -print("=" * 80)