feat: 新增跨平台消息互通插件及适配器优化
refactor(discord_adapter): 优化音频处理与心跳机制 feat(plugins/discord-cross): 实现QQ与Discord消息互通功能 fix(events/base): 添加platform字段到基础事件模型
This commit is contained in:
@@ -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
|
||||
@@ -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调用超时
|
||||
|
||||
**原因**:选择的客户端不健康或网络问题
|
||||
|
||||
**解决**:系统会自动切换到其他健康客户端
|
||||
|
||||
### 问题:所有客户端都不健康
|
||||
|
||||
**原因**:前端断开连接或网络问题
|
||||
|
||||
**解决**:检查前端连接状态和网络连接
|
||||
@@ -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("心跳任务已启动")
|
||||
|
||||
@@ -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> 或 <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:
|
||||
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}>"
|
||||
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:
|
||||
|
||||
@@ -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 实现")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
24
plugins/discord-cross/__init__.py
Normal file
24
plugins/discord-cross/__init__.py
Normal 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())
|
||||
61
plugins/discord-cross/config.py
Normal file
61
plugins/discord-cross/config.py
Normal 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()
|
||||
229
plugins/discord-cross/handlers.py
Normal file
229
plugins/discord-cross/handlers.py
Normal 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("跨平台配置已重载")
|
||||
364
plugins/discord-cross/parser.py
Normal file
364
plugins/discord-cross/parser.py
Normal 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
|
||||
165
plugins/discord-cross/sender.py
Normal file
165
plugins/discord-cross/sender.py
Normal 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}")
|
||||
81
plugins/discord-cross/subscription.py
Normal file
81
plugins/discord-cross/subscription.py
Normal 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] 跨平台消息订阅已停止")
|
||||
152
plugins/discord-cross/translator.py
Normal file
152
plugins/discord-cross/translator.py
Normal 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
|
||||
0
plugins/osu!_plugin/__init__.py
Normal file
0
plugins/osu!_plugin/__init__.py
Normal file
11
plugins/osu!_plugin/test.py
Normal file
11
plugins/osu!_plugin/test.py
Normal 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)
|
||||
@@ -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())
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
117
setup_mypyc.py
117
setup_mypyc.py
@@ -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)
|
||||
|
||||
@@ -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所有测试完成!")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user