Merge pull request #70 from Fairy-Oracle-Sanctuary/dev

feat(跨平台): 增强跨平台消息互通功能
This commit is contained in:
镀铬酸钾
2026-03-15 16:49:12 +08:00
committed by GitHub
3 changed files with 422 additions and 101 deletions

View File

@@ -6,9 +6,12 @@
- 在消息中自动标注来源平台和子频道/群组 ID
- 支持 OneBot v11 协议和数据结构
- 支持图片、视频等媒体消息
- 支持合并转发消息
"""
import asyncio
import html
import json
import os
import re
import time
from typing import Dict, List, Optional, Any
@@ -35,6 +38,236 @@ CROSS_PLATFORM_CHANNEL = "neobot_cross_platform"
ENABLE_CROSS_PLATFORM = True
async def parse_forward_nodes(nodes: List[Dict[str, Any]]) -> tuple[str, List[dict]]:
"""
解析 OneBot 合并转发消息节点
Args:
nodes: 合并转发消息节点列表
Returns:
格式化后的消息内容和附件列表
"""
content_parts = []
attachments = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data", {})
node_content = node_data.get("content", "")
# 获取发送者信息
sender_name = node_data.get("name", node_data.get("uin", "Unknown"))
# 解析节点内容
if isinstance(node_content, str):
# 检查是否是 [object Object] 格式OneBot 协议的特殊格式)
if "[object Object]" in node_content:
# OneBot 协议中,合并转发消息的 content 可能是 [object Object],[object Object]
# 实际的消息内容在 nodes 中,直接使用节点作为消息内容
content = f"[合并转发消息: {sender_name}]"
content_parts.append(f"**{sender_name}**:\n{content}")
elif '[CQ:' in node_content:
# CQ 码字符串格式
content = parse_cq_code(node_content, attachments)
content_parts.append(f"**{sender_name}**:\n{content}")
else:
content = node_content
content_parts.append(f"**{sender_name}**:\n{content}")
elif isinstance(node_content, list):
# MessageSegment 列表格式
content = parse_message_segments(node_content, attachments)
content_parts.append(f"**{sender_name}**:\n{content}")
# 组合完整消息
full_content = "\n\n".join(content_parts) if content_parts else ""
return full_content, attachments
def parse_cq_code(cq_code: str, attachments: List[dict]) -> str:
"""
解析 CQ 码字符串
Args:
cq_code: CQ 码字符串
attachments: 附件列表(用于添加图片/视频 URL
Returns:
解析后的文本内容
"""
import re
# 匹配 CQ 码
cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]'
matches = list(re.finditer(cq_pattern, cq_code))
if not matches:
return cq_code
result = []
last_end = 0
for match in matches:
if match.start() > last_end:
result.append(cq_code[last_end:match.start()])
cq_type = match.group(1)
cq_params_str = match.group(2) or ""
params = {}
if cq_params_str:
for param in cq_params_str.split(','):
if '=' in param:
k, v = param.split('=', 1)
params[k] = v
if cq_type == "text":
result.append(params.get("text", ""))
elif cq_type == "image":
file_url = params.get("url") or params.get("file")
if file_url:
file_name = params.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif cq_type == "video":
file_url = params.get("url") or params.get("file")
if file_url:
file_name = params.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif cq_type == "at":
qq_id = params.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
elif cq_type == "face":
face_id = params.get("id", "")
result.append(f"[表情:{face_id}] ")
elif cq_type == "reply":
reply_id = params.get("id", "")
result.append(f"[回复:{reply_id}] ")
elif cq_type == "file":
file_url = params.get("file", "")
if file_url:
file_name = os.path.basename(str(file_url).split('?')[0]) or "file"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[文件: {file_name}]\n")
last_end = match.end()
if last_end < len(cq_code):
result.append(cq_code[last_end:])
return "".join(result)
def parse_message_segments(segments: List[Any], attachments: List[dict]) -> str:
"""
解析 MessageSegment 列表
Args:
segments: MessageSegment 列表
attachments: 附件列表(用于添加图片/视频 URL
Returns:
解析后的文本内容
"""
result = []
for seg in segments:
if isinstance(seg, str):
result.append(seg)
elif isinstance(seg, MessageSegment):
seg_type = seg.type
seg_data = seg.data
if seg_type == "text":
result.append(seg_data.get("text", ""))
elif seg_type == "image":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif seg_type == "video":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif seg_type == "at":
qq_id = seg_data.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
elif seg_type == "face":
face_id = seg_data.get("id", "")
result.append(f"[表情:{face_id}] ")
elif seg_type == "reply":
reply_id = seg_data.get("id", "")
result.append(f"[回复:{reply_id}] ")
elif seg_type == "file":
file_url = seg_data.get("file", "")
if file_url:
file_name = os.path.basename(str(file_url).split('?')[0]) or "file"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[文件: {file_name}]\n")
elif seg_type == "json":
# 尝试解析 JSON 数据
json_data = seg_data.get("data", "")
try:
parsed = json.loads(json_data)
if isinstance(parsed, dict):
result.append(f"\n[JSON数据: {json_data[:100]}...]\n")
except:
result.append(f"\n[JSON数据]\n")
elif seg_type == "xml":
result.append(f"\n[XML数据]\n")
elif isinstance(seg, dict):
seg_type = seg.get("type")
seg_data = seg.get("data", {})
if seg_type == "text":
result.append(seg_data.get("text", ""))
elif seg_type == "image":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "image"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[图片: {file_name}]\n")
elif seg_type == "video":
file_url = seg_data.get("url") or seg_data.get("file")
if file_url:
file_name = seg_data.get("file", "")
if not file_name:
file_name = os.path.basename(str(file_url).split('?')[0]) or "video"
attachments.append({"url": str(file_url), "filename": file_name})
result.append(f"\n[视频: {file_name}]\n")
elif seg_type == "at":
qq_id = seg_data.get("qq")
if qq_id == "all":
result.append("@所有人 ")
else:
result.append(f"@{qq_id} ")
return "".join(result)
def get_platform_info(platform: str, identifier: Any) -> str:
"""
获取平台信息字符串,用于在消息中标注来源
@@ -55,7 +288,7 @@ def get_platform_info(platform: str, identifier: Any) -> str:
return f"[Discord]"
elif platform == "qq":
group_id = int(identifier)
return f"[QQ {group_id}]"
return f"[PAW qq]"
return ""
@@ -64,7 +297,7 @@ async def format_discord_to_qq_content(
discord_discriminator: str,
content: str,
channel_id: int,
attachments: List[str] = None
attachments: List[dict] = None
) -> tuple[str, List[str]]:
"""
将 Discord 消息格式化为 QQ 消息格式
@@ -93,7 +326,18 @@ async def format_discord_to_qq_content(
else:
full_message = message_header
return full_message, attachments or []
# 提取图片 URL
image_list = []
if attachments:
for att in attachments:
if isinstance(att, dict):
url = att.get("url", "")
if url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
image_list.append(url)
else:
image_list.append(str(att))
return full_message, image_list
async def format_qq_to_discord_content(
@@ -102,10 +346,10 @@ async def format_qq_to_discord_content(
group_name: str,
group_id: int,
content: str,
attachments: List[str] = None
) -> tuple[str, List[str]]:
attachments: List[dict] = None
) -> tuple[str, List[dict], dict]:
"""
将 QQ 消息格式化为 Discord 消息格式
将 QQ 消息格式化为 Discord 消息格式Embed 卡片)
Args:
qq_nickname: QQ 昵称
@@ -116,26 +360,53 @@ async def format_qq_to_discord_content(
attachments: 附件列表
Returns:
格式化后的消息内容和图片列表
格式化后的消息内容、附件列表和 Embed 字典
"""
platform_info = get_platform_info("qq", group_id)
# 构建消息头(简化版,只显示名字)
message_header = f"{platform_info} {qq_nickname}:"
# 构建 Embed 卡片
embed = {
"type": "rich",
"color": 0x5865F2, # Discord 蓝色
"author": {
"name": f"{platform_info} {qq_nickname}",
"icon_url": f"https://q1.qlogo.cn/g?b=qq&nk={qq_user_id}&s=640"
},
"description": content if content else "",
"timestamp": None,
"footer": {
"text": f"来自 QQPAW"
}
}
# 构建消息体
message_body = content if content else ""
# 如果有附件,添加到 description
if attachments:
image_urls = []
other_urls = []
for att in attachments:
if att.get("url", "").lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
image_urls.append(att.get("url"))
else:
other_urls.append(att.get("url"))
if image_urls:
embed["description"] = f"{content}\n\n{chr(10).join(image_urls[:3])}" if content else chr(10).join(image_urls[:3])
if len(image_urls) > 3:
embed["description"] += f"\n...还有 {len(image_urls) - 3} 张图片"
# 附加文件列表
if other_urls:
file_list = "\n".join([f"📄 {os.path.basename(u.split('?')[0])}" for u in other_urls[:5]])
embed["description"] += f"\n\n**附加文件:**\n{file_list}"
if len(other_urls) > 5:
embed["description"] += f"\n...还有 {len(other_urls) - 5} 个文件"
# 组合完整消息(移除分隔符)
if message_body:
full_message = f"{message_header} {message_body}"
else:
full_message = message_header
return full_message, attachments or []
# 对于合并转发消息content 为空,只发送 embed
# 对于普通消息content 也为空,只发送 embed
return "", attachments or [], embed
async def send_to_discord(channel_id: int, content: str, attachments: List[str] = None):
async def send_to_discord(channel_id: int, content: str, attachments: List[dict] = None, embed: dict = None):
"""
发送消息到 Discord 频道
@@ -145,14 +416,16 @@ async def send_to_discord(channel_id: int, content: str, attachments: List[str]
Args:
channel_id: Discord 频道 ID
content: 消息内容
attachments: 附件 URL 列表
attachments: 附件列表,每个元素为 {"url": str, "filename": str}
embed: Discord Embed 字典
"""
try:
publish_data = {
"type": "send_message",
"channel_id": channel_id,
"content": content,
"attachments": attachments or []
"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}")
@@ -161,14 +434,14 @@ async def send_to_discord(channel_id: int, content: str, attachments: List[str]
logger.error(f"[CrossPlatform] 发送消息到 Discord 失败: {e}")
async def send_to_qq(group_id: int, content: str, attachments: List[str] = None):
async def send_to_qq(group_id: int, content: str, attachments: List[dict] = None):
"""
发送消息到 QQ 群
Args:
group_id: QQ 群 ID
content: 消息内容
attachments: 附件 URL 列表
attachments: 附件列表,每个元素为 {"url": str, "filename": str}
"""
try:
from core.managers.bot_manager import bot_manager
@@ -196,7 +469,11 @@ async def send_to_qq(group_id: int, content: str, attachments: List[str] = None)
if content:
full_message.append(MessageSegment.text(content))
for attachment in attachments:
full_message.append(MessageSegment.image(attachment, cache=True, proxy=True, timeout=30))
if isinstance(attachment, dict):
attachment_url = attachment.get("url", "")
else:
attachment_url = str(attachment)
full_message.append(MessageSegment.image(attachment_url, cache=True, proxy=True, timeout=30))
logger.debug(f"[CrossPlatform] 准备发送消息到 QQ 群 {group_id}: {full_message}")
# 一次性发送
@@ -219,7 +496,7 @@ async def forward_discord_to_qq(
discord_discriminator: str,
content: str,
channel_id: int,
attachments: List[str] = None
attachments: List[dict] = None
):
"""
将 Discord 消息转发到所有映射的 QQ 群
@@ -259,7 +536,7 @@ async def forward_qq_to_discord(
group_name: str,
group_id: int,
content: str,
attachments: List[str] = None
attachments: List[dict] = None
):
"""
将 QQ 消息转发到所有映射的 Discord 频道
@@ -283,7 +560,7 @@ async def forward_qq_to_discord(
return
# 格式化消息
formatted_content, image_list = await format_qq_to_discord_content(
formatted_content, image_list, embed = await format_qq_to_discord_content(
qq_nickname,
qq_user_id,
group_name,
@@ -294,7 +571,7 @@ async def forward_qq_to_discord(
# 发送到所有映射的 Discord 频道
for channel_id in target_channels:
await send_to_discord(channel_id, formatted_content, image_list)
await send_to_discord(channel_id, formatted_content, image_list, embed)
logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}")
@@ -325,7 +602,8 @@ async def handle_discord_message(
discriminator: str,
content: str,
channel_id: int,
attachments: List[str] = None
attachments: List[dict] = None,
embed: dict = None
):
"""
处理 Discord 消息并转发
@@ -336,6 +614,7 @@ async def handle_discord_message(
content: 消息内容
channel_id: Discord 频道 ID
attachments: 附件列表
embed: Discord Embed 字典
"""
if not ENABLE_CROSS_PLATFORM:
return
@@ -352,7 +631,7 @@ async def handle_qq_message(
group_name: str,
group_id: int,
content: str,
attachments: List[str] = None
attachments: List[dict] = None
):
"""
处理 QQ 消息并转发
@@ -398,26 +677,45 @@ async def handle_qq_group_message(event: GroupMessageEvent):
attachments = []
if isinstance(event.message, list):
for segment in event.message:
if isinstance(segment, MessageSegment):
if segment.type == "text":
content += segment.data.get("text", "")
elif segment.type == "image":
file_url = segment.data.get("url") or segment.data.get("file")
if file_url:
attachments.append(str(file_url))
elif segment.type == "video":
file_url = segment.data.get("url") or segment.data.get("file")
if file_url:
attachments.append(str(file_url))
elif segment.type == "at":
qq_id = segment.data.get("qq")
if qq_id and qq_id != "all":
content += f"@{qq_id} "
elif qq_id == "all":
content += "@所有人 "
elif isinstance(segment, str):
content += segment
# 检查是否是合并转发消息
has_forward_node = any(isinstance(seg, MessageSegment) and seg.type == "node" for seg in event.message)
if has_forward_node:
# 解析合并转发消息
forward_nodes = [seg for seg in event.message if isinstance(seg, MessageSegment) and seg.type == "node"]
# 将 MessageSegment 转换为字典格式
forward_nodes_dict = [{"type": seg.type, "data": seg.data} for seg in forward_nodes]
content, attachments = await parse_forward_nodes(forward_nodes_dict)
else:
# 普通消息
for segment in event.message:
if isinstance(segment, MessageSegment):
if segment.type == "text":
content += segment.data.get("text", "")
elif segment.type == "image":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("file", "")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"image_{len(attachments)}.jpg"
attachments.append({"url": file_url, "filename": file_name})
elif segment.type == "video":
file_url = segment.data.get("url") or segment.data.get("file")
file_name = segment.data.get("file", "")
if file_url:
file_url = html.unescape(str(file_url))
if not file_name:
file_name = os.path.basename(file_url.split('?')[0]) or f"video_{len(attachments)}.mp4"
attachments.append({"url": file_url, "filename": file_name})
elif segment.type == "at":
qq_id = segment.data.get("qq")
if qq_id and qq_id != "all":
content += f"@{qq_id} "
elif qq_id == "all":
content += "@所有人 "
elif isinstance(segment, str):
content += segment
elif isinstance(event.message, str):
content = event.message
@@ -473,8 +771,12 @@ async def handle_discord_message_event(event: Any):
line = line.strip()
if re.match(url_pattern, line):
# 这是附件 URL
if line not in attachments:
attachments.append(line)
attachment_url = line
# 尝试从 URL 提取文件名
filename = os.path.basename(attachment_url.split('?')[0]) or "attachment"
attachment_item = {"url": attachment_url, "filename": filename}
if attachment_item not in attachments:
attachments.append(attachment_item)
else:
# 这是普通文本内容
if line:
@@ -490,12 +792,20 @@ async def handle_discord_message_event(event: Any):
pass # 已经在 raw_message 中
elif segment.type == "image":
file_url = segment.data.get("url") or segment.data.get("file")
if file_url and str(file_url) not in attachments:
attachments.append(str(file_url))
file_name = segment.data.get("file", "")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "image"
attachment_item = {"url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
elif segment.type == "video":
file_url = segment.data.get("url") or segment.data.get("file")
if file_url and str(file_url) not in attachments:
attachments.append(str(file_url))
file_name = segment.data.get("file", "")
if file_url:
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video"
attachment_item = {"url": str(file_url), "filename": file_name}
if attachment_item not in attachments:
attachments.append(attachment_item)
# 获取用户信息
discord_username = getattr(event, 'discord_username', 'Unknown')
@@ -507,7 +817,8 @@ async def handle_discord_message_event(event: Any):
discriminator=discord_discriminator,
content=content,
channel_id=discord_channel_id,
attachments=attachments
attachments=attachments,
embed=None
)
@@ -551,7 +862,8 @@ async def cross_platform_subscription_loop():
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", [])
attachments=message_data.get("attachments", []),
embed=message_data.get("embed")
)
except json.JSONDecodeError as e: