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

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

View File

@@ -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("心跳任务已启动")

View File

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

View File

@@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
"""
通用数据模型 (Universal Data Models)
此模块定义了平台无关的数据结构,用于在不同平台(如 OneBot, Discord
和业务逻辑层(如 Plugins之间传递数据。
"""
from dataclasses import dataclass, field
from typing import List, Optional, Union, Dict, Any
@dataclass
class UniversalMessageSegment:
"""
平台无关的通用消息段模型。
业务逻辑层只负责生成这个对象,由底层的 Adapter 负责将其翻译成特定平台的格式。
"""
type: str # 消息类型:'text', 'image', 'video', 'audio', 'at', 'reply' 等
data: Dict[str, Any] # 消息数据载荷
@staticmethod
def text(text: str) -> "UniversalMessageSegment":
return UniversalMessageSegment("text", {"text": text})
@staticmethod
def image(url: Optional[str] = None, base64: Optional[str] = None, file_path: Optional[str] = None) -> "UniversalMessageSegment":
"""
图片消息。
Discord 支持直接发 URL 或上传本地文件OneBot 支持 URL、Base64 或本地路径。
"""
return UniversalMessageSegment("image", {"url": url, "base64": base64, "file_path": file_path})
@staticmethod
def video(url: Optional[str] = None, file_path: Optional[str] = None) -> "UniversalMessageSegment":
"""
视频消息。
Discord 通常直接发 URL 或作为附件上传OneBot 支持 URL 或本地路径。
"""
return UniversalMessageSegment("video", {"url": url, "file_path": file_path})
@staticmethod
def at(user_id: str) -> "UniversalMessageSegment":
"""
@某人。
注意:为了兼容 Discord 的雪花 ID (Snowflake)user_id 必须是字符串。
"""
return UniversalMessageSegment("at", {"user_id": user_id})
@staticmethod
def reply(message_id: str) -> "UniversalMessageSegment":
"""
回复某条消息。
"""
return UniversalMessageSegment("reply", {"message_id": message_id})
@dataclass
class UniversalUser:
"""通用用户模型"""
id: str # 用户唯一ID (QQ号 或 Discord Snowflake ID)
name: str # 用户昵称/群名片
avatar_url: str # 头像URL
is_bot: bool # 是否是机器人
@dataclass
class UniversalChannel:
"""通用频道/群组模型"""
id: str # 频道/群组唯一ID (QQ群号 或 Discord Channel ID)
name: str # 频道/群组名称
type: str # 类型:'private' (私聊), 'group' (QQ群), 'guild_text' (Discord文字频道) 等
guild_id: Optional[str] = None # 仅 Discord 有效:服务器(Guild) ID
@dataclass
class UniversalMessageEvent:
"""
平台无关的通用消息事件模型。
这是传递给业务逻辑层(如 bili.py的最终对象。
"""
platform: str # 来源平台标识:'onebot' 或 'discord'
message_id: str # 消息唯一ID (QQ消息ID 或 Discord Message ID)
user: UniversalUser # 发送者信息
channel: UniversalChannel # 消息来源频道/群组信息
raw_message: str # 纯文本形式的消息内容(用于正则匹配、命令解析)
# 解析后的消息段列表(可选,如果你需要处理图文混排)
message: List[UniversalMessageSegment] = field(default_factory=list)
# 原始的底层事件对象(保留引用,方便高级操作)
# 例如OneBot 的原始 JSON 字典,或 discord.py 的 discord.Message 对象
raw_event: Any = field(repr=False, default=None)
async def reply(self, message: Union[str, UniversalMessageSegment, List[UniversalMessageSegment]]):
"""
统一的回复接口。
这个方法应该是一个抽象方法或由具体的 Adapter 注入实现。
业务逻辑层调用此方法时,不需要关心底层是调用 OneBot API 还是 Discord API。
"""
raise NotImplementedError("此方法应由具体的 Platform Adapter 实现")