feat: 新增跨平台消息互通插件及适配器优化

refactor(discord_adapter): 优化音频处理与心跳机制
feat(plugins/discord-cross): 实现QQ与Discord消息互通功能
fix(events/base): 添加platform字段到基础事件模型
This commit is contained in:
2026-03-21 13:44:36 +08:00
parent 3814f49fcf
commit 51fb77e6e0
23 changed files with 1562 additions and 2148 deletions

View File

@@ -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

View File

@@ -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调用超时
**原因**:选择的客户端不健康或网络问题
**解决**:系统会自动切换到其他健康客户端
### 问题:所有客户端都不健康
**原因**:前端断开连接或网络问题
**解决**:检查前端连接状态和网络连接

View File

@@ -3,14 +3,16 @@
Discord 适配器 (Discord Adapter) Discord 适配器 (Discord Adapter)
此模块负责与 Discord API 建立连接,接收 Discord 消息, 此模块负责与 Discord API 建立连接,接收 Discord 消息,
并将其转换为通用数据模型 (Universal Data Models) 并将其转换为本地 OneBot 数据模型
同时提供将通用消息段发送回 Discord 的能力。 同时提供将本地消息段发送回 Discord 的能力。
""" """
import asyncio import asyncio
import json import json
import os import os
import io import io
import requests import requests
import tempfile
import subprocess
from typing import Union, List, Optional from typing import Union, List, Optional
try: try:
@@ -61,6 +63,8 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
"""当 Bot 成功连接到 Discord 时触发""" """当 Bot 成功连接到 Discord 时触发"""
self.logger.success(f"Discord Bot 已登录: {self.user} (ID: {self.user.id})") self.logger.success(f"Discord Bot 已登录: {self.user} (ID: {self.user.id})")
self.start_heartbeat_task(interval=30)
# 启动 Redis 订阅以处理跨平台消息 # 启动 Redis 订阅以处理跨平台消息
asyncio.create_task(self.start_redis_subscription()) asyncio.create_task(self.start_redis_subscription())
@@ -112,6 +116,61 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
except Exception as e: except Exception as e:
self.logger.error(f"[DiscordAdapter] Redis 订阅异常: {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): async def handle_send_message(self, data: dict):
"""处理来自 Redis 的消息发送请求""" """处理来自 Redis 的消息发送请求"""
try: try:
@@ -131,6 +190,10 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}") self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}")
embed = None
if embed_data:
embed = discord.Embed.from_dict(embed_data)
files = [] files = []
if attachments: if attachments:
proxies = None proxies = None
@@ -153,14 +216,60 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
response = requests.get(attachment_url, proxies=proxies, timeout=30) response = requests.get(attachment_url, proxies=proxies, timeout=30)
if not filename: if not filename:
filename = os.path.basename(attachment_url.split('?')[0]) or "attachment" 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: except Exception as e:
self.logger.error(f"[DiscordAdapter] 下载附件失败: {attachment_url}, 错误: {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: if content or files or embed:
await channel.send(content=content, files=files if files else None, embed=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: except Exception as e:
self.logger.error(f"[DiscordAdapter] 发送消息失败: {e}") self.logger.error(f"[DiscordAdapter] 发送消息失败: {e}")
async def start_client(self): async def start_client(self, max_retries: int = -1, retry_delay: int = 5):
"""启动 Discord 客户端(非阻塞方式)""" """
启动 Discord 客户端
Args:
max_retries: 最大重连次数,-1 表示无限重连
retry_delay: 重连延迟(秒)
"""
if not DISCORD_AVAILABLE: if not DISCORD_AVAILABLE:
self.logger.error("无法启动 Discord 客户端discord.py 未安装") self.logger.error("无法启动 Discord 客户端discord.py 未安装")
return return
try: retry_count = 0
self.logger.info("正在连接 Discord...")
await self.start(self.token) while max_retries == -1 or retry_count < max_retries:
except Exception as e: try:
self.logger.error(f"Discord 连接失败: {e}") 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("心跳任务已启动")

View File

@@ -12,7 +12,7 @@
4. 将插件返回的 OneBot `MessageSegment` 转换为 Discord 格式并发送。 4. 将插件返回的 OneBot `MessageSegment` 转换为 Discord 格式并发送。
""" """
import asyncio import asyncio
from typing import Union, List, Any, Optional from typing import Union, List, Any, Optional, Dict
try: try:
import discord import discord
@@ -27,6 +27,189 @@ from core.utils.logger import ModuleLogger
logger = ModuleLogger("EventRouter") 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: class DiscordToOneBotConverter:
""" """
将 Discord 消息转换为 OneBot 消息事件的转换器。 将 Discord 消息转换为 OneBot 消息事件的转换器。
@@ -53,13 +236,85 @@ class DiscordToOneBotConverter:
# 我们需要把前面的 @ 提及去掉,否则命令匹配器 (matcher) 无法识别以 "/" 开头的命令 # 我们需要把前面的 @ 提及去掉,否则命令匹配器 (matcher) 无法识别以 "/" 开头的命令
raw_message = discord_message.content raw_message = discord_message.content
# 添加附件信息到 raw_message # 构造 message 列表 (将文本和附件转换为 MessageSegment)
message_list = []
# 添加文本内容
if discord_message.content:
# 处理 Discord 自定义表情 <:name:id> 或 <a:name:id>
import re
content = discord_message.content
# 查找所有自定义表情
emoji_pattern = r'<a?:([^:]+):(\d+)>'
# 如果有表情,我们需要将文本分割成多个片段
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('<a:')
ext = 'gif' if is_animated else 'png'
emoji_url = f"https://cdn.discordapp.com/emojis/{emoji_id}.{ext}"
seg = OneBotMessageSegment.image(emoji_url)
seg.data["filename"] = f"{emoji_name}.{ext}"
message_list.append(seg)
last_end = match.end()
# 添加剩余的文本
if last_end < len(content):
text_part = content[last_end:]
if text_part:
message_list.append(OneBotMessageSegment.text(text_part))
else:
message_list.append(OneBotMessageSegment.text(content))
# 添加附件信息
if discord_message.attachments: if discord_message.attachments:
for attachment in discord_message.attachments: for attachment in discord_message.attachments:
raw_message += f"\n{attachment.url}" filename = attachment.filename.lower()
# 检查是否是语音文件
if filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')):
seg = OneBotMessageSegment.record(attachment.url)
seg.data["filename"] = attachment.filename
message_list.append(seg)
raw_message += f"\n[语音: {attachment.filename}]"
elif filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')):
seg = OneBotMessageSegment.video(attachment.url)
seg.data["filename"] = attachment.filename
message_list.append(seg)
raw_message += f"\n[视频: {attachment.filename}]"
else:
seg = OneBotMessageSegment.image(attachment.url)
seg.data["filename"] = attachment.filename
message_list.append(seg)
raw_message += f"\n[图片: {attachment.filename}]"
# 添加贴纸 (Stickers) 信息
if hasattr(discord_message, 'stickers') and discord_message.stickers:
for sticker in discord_message.stickers:
seg = OneBotMessageSegment.image(sticker.url)
seg.data["filename"] = f"{sticker.name}.png"
message_list.append(seg)
raw_message += f"\n[贴纸: {sticker.name}]"
bot_mention = f"<@{adapter.user.id}>" bot_mention = f"<@{adapter.user.id}>"
if raw_message.startswith(bot_mention): if raw_message.startswith(bot_mention):
raw_message = raw_message[len(bot_mention):].strip() 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( sender = Sender(
@@ -72,9 +327,6 @@ class DiscordToOneBotConverter:
# 2. 判断是群聊还是私聊 # 2. 判断是群聊还是私聊
is_private = isinstance(discord_message.channel, discord.DMChannel) is_private = isinstance(discord_message.channel, discord.DMChannel)
# 构造 message 列表 (将纯文本转换为 MessageSegment)
message_list = [OneBotMessageSegment.text(raw_message)]
import time import time
current_time = int(time.time()) current_time = int(time.time())
self_id = adapter.user.id if adapter.user else 0 self_id = adapter.user.id if adapter.user else 0
@@ -89,6 +341,7 @@ class DiscordToOneBotConverter:
event = PrivateMessageEvent( event = PrivateMessageEvent(
time=current_time, time=current_time,
self_id=self_id, self_id=self_id,
platform="discord",
message_type="private", message_type="private",
sub_type="friend", sub_type="friend",
message_id=message_id, message_id=message_id,
@@ -103,6 +356,7 @@ class DiscordToOneBotConverter:
event = GroupMessageEvent( event = GroupMessageEvent(
time=current_time, time=current_time,
self_id=self_id, self_id=self_id,
platform="discord",
message_type="group", message_type="group",
sub_type="normal", sub_type="normal",
message_id=message_id, message_id=message_id,
@@ -119,146 +373,14 @@ class DiscordToOneBotConverter:
event.discord_username = discord_username event.discord_username = discord_username
event.discord_discriminator = discord_discriminator event.discord_discriminator = discord_discriminator
# 3. 拦截并重写 reply 方法 (核心魔法) # 注入 DiscordBotWrapper
# 插件调用 event.reply() 时,实际上会执行这个闭包 event.bot = DiscordBotWrapper(adapter)
async def mock_reply(message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False):
await DiscordToOneBotConverter.send_discord_reply(discord_message, message, adapter)
# 覆盖实例方法
event.reply = mock_reply
# 注入一个假的 bot 对象,防止插件调用 event.bot.xxx 时报错
# 这里只提供最基础的属性,如果插件调用了复杂的 API可能会报错
class MockBot:
def __init__(self):
self.self_id = adapter.user.id if adapter.user else 0
async def send(self, event, message, **kwargs):
await DiscordToOneBotConverter.send_discord_reply(discord_message, message, adapter)
async def send_forwarded_messages(self, target, nodes):
"""
模拟发送合并转发消息。
Discord 不支持像 QQ 那样的合并转发,所以我们将其转换为普通消息发送。
"""
content = ""
files = []
for node in nodes:
if node.get("type") == "node":
node_data = node.get("data", {})
node_content = node_data.get("content", [])
# 提取节点中的文本和图片
if isinstance(node_content, str):
# 尝试解析 CQ 码
import re
cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]'
matches = list(re.finditer(cq_pattern, node_content))
if not matches:
content += f"{node_content}\n"
else:
last_end = 0
for match in matches:
if match.start() > last_end:
content += node_content[last_end:match.start()]
cq_type = match.group(1)
cq_params_str = match.group(2) or ""
params = {}
if cq_params_str:
for param in cq_params_str.split(','):
if '=' in param:
k, v = param.split('=', 1)
params[k] = v
if cq_type in ("image", "video"):
file_url = params.get("url") or params.get("file")
if file_url:
if str(file_url).startswith("http"):
content += f"\n{file_url}\n"
elif str(file_url).startswith("base64://"):
import base64
import io
b64_data = str(file_url)[9:]
if b64_data.startswith("data:image"):
b64_data = b64_data.split(",", 1)[1]
try:
image_bytes = base64.b64decode(b64_data)
files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png"))
except Exception as e:
logger.error(f"解析 Base64 图片失败: {e}")
else:
try:
files.append(discord.File(file_url))
except Exception as e:
logger.error(f"无法读取本地文件 {file_url}: {e}")
elif cq_type == "at":
qq_id = params.get("qq")
if qq_id == "all":
content += "@everyone "
else:
content += f"<@{qq_id}> "
last_end = match.end()
if last_end < len(node_content):
content += node_content[last_end:]
content += "\n"
elif isinstance(node_content, list):
for seg in node_content:
if isinstance(seg, dict):
seg_type = seg.get("type")
seg_data = seg.get("data", {})
if seg_type == "text":
content += seg_data.get("text", "")
elif seg_type == "image" or seg_type == "video":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
if isinstance(file_url, bytes):
import io
try:
files.append(discord.File(fp=io.BytesIO(file_url), filename="image.png"))
except Exception as e:
logger.error(f"解析 bytes 图片失败: {e}")
elif str(file_url).startswith("http"):
content += f"\n{file_url}\n"
elif str(file_url).startswith("base64://") or "data:image" in str(file_url):
import base64
import io
b64_data = str(file_url)
if b64_data.startswith("base64://"):
b64_data = b64_data[9:]
if b64_data.startswith("data:image"):
b64_data = b64_data.split(",", 1)[1]
try:
image_bytes = base64.b64decode(b64_data)
files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png"))
except Exception as e:
logger.error(f"解析 Base64 图片失败: {e}")
else:
try:
files.append(discord.File(file_url))
except Exception as e:
logger.error(f"无法读取本地文件 {file_url}: {e}")
content += "\n"
try:
if content or files:
await discord_message.channel.send(content=content, files=files if files else None)
except Exception as e:
logger.error(f"发送 Discord 合并转发消息失败: {e}")
event.bot = MockBot()
return event return event
@staticmethod @staticmethod
async def send_discord_reply( async def send_discord_message(
original_message: 'discord.Message', channel: 'discord.abc.Messageable',
message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]],
adapter: Any adapter: Any
): ):
@@ -266,7 +388,7 @@ class DiscordToOneBotConverter:
将 OneBot 的消息段转换为 Discord 格式并发送。 将 OneBot 的消息段转换为 Discord 格式并发送。
Args: Args:
original_message: 触发此回复的原始 Discord 消息 channel: Discord 频道对象 (TextChannel, DMChannel 等)
message: 插件返回的 OneBot 消息内容 (字符串或 MessageSegment 列表) message: 插件返回的 OneBot 消息内容 (字符串或 MessageSegment 列表)
adapter: DiscordAdapter 实例 adapter: DiscordAdapter 实例
""" """
@@ -306,29 +428,33 @@ class DiscordToOneBotConverter:
k, v = param.split('=', 1) k, v = param.split('=', 1)
params[k] = v 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") file_url = params.get("url") or params.get("file")
if file_url: if file_url:
if str(file_url).startswith("http"): if str(file_url).startswith("http"):
content += f"\n{file_url}" 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 base64
import io import io
b64_data = str(file_url) b64_data = str(file_url)
if b64_data.startswith("base64://"): if b64_data.startswith("base64://"):
b64_data = b64_data[9:] 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] b64_data = b64_data.split(",", 1)[1]
try: try:
image_bytes = base64.b64decode(b64_data) file_bytes = base64.b64decode(b64_data)
files.append(discord.File(fp=io.BytesIO(image_bytes), filename="image.png")) 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: except Exception as e:
logger.error(f"解析 Base64 图片失败: {e}") logger.error(f"解析 Base64 文件失败: {e}")
else: else:
try: try:
files.append(discord.File(file_url)) files.append(discord.File(file_url))
except Exception as e: except Exception as e:
logger.error(f"无法读取本地文件 {file_url}: {e}") logger.error(f"无法读取本地文件 {file_url}: {e}")
elif cq_type == "face":
face_id = params.get("id")
content += f"[表情:{face_id}]"
elif cq_type == "at": elif cq_type == "at":
qq_id = params.get("qq") qq_id = params.get("qq")
if qq_id == "all": if qq_id == "all":
@@ -349,8 +475,8 @@ class DiscordToOneBotConverter:
if seg_type == "text": if seg_type == "text":
content += seg_data.get("text", "") content += seg_data.get("text", "")
elif seg_type == "image" or seg_type == "video": elif seg_type in ("image", "video", "record"):
# OneBot 的图片/视频通常有 file (URL或本地路径) 或 url 字段 # OneBot 的图片/视频/语音通常有 file (URL或本地路径) 或 url 字段
file_url = seg_data.get("url") or seg_data.get("file") file_url = seg_data.get("url") or seg_data.get("file")
if file_url: if file_url:
@@ -358,32 +484,37 @@ class DiscordToOneBotConverter:
if isinstance(file_url, bytes): if isinstance(file_url, bytes):
import io import io
try: 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: except Exception as e:
logger.error(f"解析 bytes 图片失败: {e}") logger.error(f"解析 bytes 文件失败: {e}")
elif str(file_url).startswith("http"): elif str(file_url).startswith("http"):
# 如果是网络 URL直接拼接到文本中Discord 会自动解析预览 # 如果是网络 URL直接拼接到文本中Discord 会自动解析预览
content += f"\n{file_url}" 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):
# 处理 Base64 图片 (需要解码并作为文件上传) # 处理 Base64 文件 (需要解码并作为文件上传)
import base64 import base64
import io import io
b64_data = str(file_url) b64_data = str(file_url)
if b64_data.startswith("base64://"): if b64_data.startswith("base64://"):
b64_data = b64_data[9:] 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] b64_data = b64_data.split(",", 1)[1]
try: try:
image_bytes = base64.b64decode(b64_data) file_bytes = base64.b64decode(b64_data)
files.append(discord.File(fp=io.BytesIO(image_bytes), 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_bytes), filename=filename))
except Exception as e: except Exception as e:
logger.error(f"解析 Base64 图片失败: {e}") logger.error(f"解析 Base64 文件失败: {e}")
else: else:
# 假设是本地文件路径 # 假设是本地文件路径
try: try:
files.append(discord.File(file_url)) files.append(discord.File(file_url))
except Exception as e: except Exception as e:
logger.error(f"无法读取本地文件 {file_url}: {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": elif seg_type == "at":
qq_id = seg_data.get("qq") qq_id = seg_data.get("qq")
if qq_id == "all": if qq_id == "all":
@@ -399,7 +530,7 @@ class DiscordToOneBotConverter:
try: try:
# 如果内容为空但有文件Discord 允许发送 # 如果内容为空但有文件Discord 允许发送
if content or files: 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: else:
logger.warning("尝试发送空消息到 Discord已拦截") logger.warning("尝试发送空消息到 Discord已拦截")
except Exception as e: except Exception as e:

View File

@@ -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 实现")

View File

@@ -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()

View File

@@ -45,8 +45,10 @@ class OneBotEvent(ABC):
time: int time: int
self_id: int self_id: int
platform: str = "onebot"
_bot: Optional["Bot"] = field(default=None, init=False) _bot: Optional["Bot"] = field(default=None, init=False)
@property @property
@abstractmethod @abstractmethod
def post_type(self) -> str: def post_type(self) -> str:

View File

@@ -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}")

File diff suppressed because it is too large Load Diff

View File

@@ -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())

View File

@@ -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()

View File

@@ -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("跨平台配置已重载")

View File

@@ -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

View File

@@ -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}")

View File

@@ -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] 跨平台消息订阅已停止")

View File

@@ -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

View File

View File

@@ -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)

View File

@@ -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())

View File

@@ -1,2 +0,0 @@
[pytest]
pythonpath = .

View File

@@ -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)

View File

@@ -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所有测试完成!")

View File

@@ -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)