feat(跨平台): 增强跨平台消息互通功能
- 支持合并转发消息解析和展示 - 优化附件处理逻辑,支持文件名和类型识别 - 添加 Discord Embed 卡片支持,提升消息展示效果 - 重构消息格式化和转发逻辑,提高可维护性 - 更新代理配置和日志级别设置
This commit is contained in:
@@ -33,21 +33,29 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
|
|||||||
if not DISCORD_AVAILABLE:
|
if not DISCORD_AVAILABLE:
|
||||||
raise ImportError("discord.py 未安装,请运行 `pip install discord.py`")
|
raise ImportError("discord.py 未安装,请运行 `pip install discord.py`")
|
||||||
|
|
||||||
# 必须声明 Intents,否则无法读取消息内容
|
self.logger = ModuleLogger("DiscordAdapter")
|
||||||
intents = discord.Intents.default()
|
self.token = token
|
||||||
intents.message_content = True
|
self.send_channel = None
|
||||||
|
|
||||||
# 检查是否配置了代理
|
|
||||||
self.proxy = None
|
self.proxy = None
|
||||||
self.proxy_type = "http"
|
self.proxy_type = "http"
|
||||||
if global_config.discord.proxy:
|
if global_config.discord.proxy:
|
||||||
self.proxy = global_config.discord.proxy
|
self.proxy = global_config.discord.proxy
|
||||||
self.proxy_type = global_config.discord.proxy_type or "http"
|
self.proxy_type = global_config.discord.proxy_type or "http"
|
||||||
|
|
||||||
|
proxy_url = self.proxy
|
||||||
|
if self.proxy_type.lower() in ["socks5", "socks4"]:
|
||||||
|
if not proxy_url.startswith(("socks5://", "socks4://")):
|
||||||
|
proxy_url = f"{self.proxy_type.lower()}://{proxy_url.split('://')[-1]}"
|
||||||
|
|
||||||
|
os.environ["HTTP_PROXY"] = proxy_url
|
||||||
|
os.environ["HTTPS_PROXY"] = proxy_url
|
||||||
|
self.logger.info(f"[DiscordAdapter] 代理已设置: {proxy_url} (类型: {self.proxy_type})")
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
|
||||||
super().__init__(intents=intents)
|
super().__init__(intents=intents)
|
||||||
self.token = token
|
|
||||||
self.logger = ModuleLogger("DiscordAdapter")
|
|
||||||
self.send_channel = None
|
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
"""当 Bot 成功连接到 Discord 时触发"""
|
"""当 Bot 成功连接到 Discord 时触发"""
|
||||||
@@ -110,6 +118,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
|
|||||||
channel_id = data.get("channel_id")
|
channel_id = data.get("channel_id")
|
||||||
content = data.get("content", "")
|
content = data.get("content", "")
|
||||||
attachments = data.get("attachments", [])
|
attachments = data.get("attachments", [])
|
||||||
|
embed_data = data.get("embed")
|
||||||
|
|
||||||
if channel_id is None:
|
if channel_id is None:
|
||||||
self.logger.error("[DiscordAdapter] 缺少 channel_id")
|
self.logger.error("[DiscordAdapter] 缺少 channel_id")
|
||||||
@@ -122,9 +131,38 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
|
|||||||
|
|
||||||
self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}")
|
self.logger.info(f"[DiscordAdapter] 正在发送消息到频道 {channel_id}")
|
||||||
|
|
||||||
# 发送内容和附件(合并为一条消息)
|
files = []
|
||||||
if content or attachments:
|
if attachments:
|
||||||
await channel.send(content=content, files=[discord.File(fp=io.BytesIO(requests.get(attachment_url).content), filename=os.path.basename(attachment_url)) for attachment_url in attachments if attachment_url.startswith('http')] if attachments else None)
|
proxies = None
|
||||||
|
if self.proxy:
|
||||||
|
proxies = {
|
||||||
|
"http": self.proxy,
|
||||||
|
"https": self.proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
for attachment in attachments:
|
||||||
|
if isinstance(attachment, dict):
|
||||||
|
attachment_url = attachment.get("url", "")
|
||||||
|
filename = attachment.get("filename", "")
|
||||||
|
else:
|
||||||
|
attachment_url = str(attachment)
|
||||||
|
filename = ""
|
||||||
|
|
||||||
|
if attachment_url.startswith('http'):
|
||||||
|
try:
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
self.logger.success(f"[DiscordAdapter] 消息已发送到频道 {channel_id}")
|
self.logger.success(f"[DiscordAdapter] 消息已发送到频道 {channel_id}")
|
||||||
|
|
||||||
@@ -139,35 +177,6 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info("正在连接 Discord...")
|
self.logger.info("正在连接 Discord...")
|
||||||
|
|
||||||
# 如果配置了代理,使用自定义的 ClientSession
|
|
||||||
if self.proxy:
|
|
||||||
import aiohttp
|
|
||||||
proxy_url = self.proxy
|
|
||||||
self.logger.info(f"[DiscordAdapter] 使用代理: {proxy_url} (类型: {self.proxy_type})")
|
|
||||||
|
|
||||||
connector = aiohttp.TCPConnector()
|
|
||||||
session = aiohttp.ClientSession(connector=connector)
|
|
||||||
|
|
||||||
# discord.py 2.0+ 使用 discord.Client 的 connector 参数
|
|
||||||
# 但 discord.Client 不直接支持自定义 connector
|
|
||||||
# 需要使用 discord.AutoShardedClient 或修改内部实现
|
|
||||||
# 这里我们使用 discord.Client 的 __init__ 传递 connector
|
|
||||||
# 但 discord.Client 的 __init__ 不支持 connector 参数
|
|
||||||
# 所以我们需要使用 discord.Client 的 _create_http_client 方法
|
|
||||||
|
|
||||||
# 简单方案:使用环境变量设置代理
|
|
||||||
import os
|
|
||||||
os.environ["HTTP_PROXY"] = proxy_url
|
|
||||||
os.environ["HTTPS_PROXY"] = proxy_url
|
|
||||||
|
|
||||||
self.logger.info("[DiscordAdapter] 代理已设置,正在连接 Discord...")
|
|
||||||
await self.start(self.token)
|
|
||||||
|
|
||||||
# 清理环境变量
|
|
||||||
os.environ.pop("HTTP_PROXY", None)
|
|
||||||
os.environ.pop("HTTPS_PROXY", None)
|
|
||||||
else:
|
|
||||||
await self.start(self.token)
|
await self.start(self.token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Discord 连接失败: {e}")
|
self.logger.error(f"Discord 连接失败: {e}")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ base_url = "http://101.36.126.55:3003" # 外部访问的 URL
|
|||||||
[discord]
|
[discord]
|
||||||
enabled = true
|
enabled = true
|
||||||
token = "MTQ4MjQzODA1NzExNzYxODI4Nw.G9R6uR.ddxHn3pmUf7SyrrOBg_-_lc7Y62lsCitPxpdGM"
|
token = "MTQ4MjQzODA1NzExNzYxODI4Nw.G9R6uR.ddxHn3pmUf7SyrrOBg_-_lc7Y62lsCitPxpdGM"
|
||||||
proxy = "http://127.0.0.1:7890"
|
proxy = "http://127.0.0.1:7897"
|
||||||
proxy_type = "http"
|
proxy_type = "http"
|
||||||
|
|
||||||
# 跨平台消息互通配置
|
# 跨平台消息互通配置
|
||||||
@@ -126,7 +126,7 @@ name = "Paw"
|
|||||||
# 日志配置
|
# 日志配置
|
||||||
[logging]
|
[logging]
|
||||||
# 控制台日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
# 控制台日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
||||||
console_level = "INFO"
|
console_level = "DEBUG"
|
||||||
# 文件日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
# 文件日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
||||||
file_level = "DEBUG"
|
file_level = "DEBUG"
|
||||||
# 全局日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
# 全局日志级别(DEBUG, INFO, SUCCESS, WARNING, ERROR)
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
- 在消息中自动标注来源平台和子频道/群组 ID
|
- 在消息中自动标注来源平台和子频道/群组 ID
|
||||||
- 支持 OneBot v11 协议和数据结构
|
- 支持 OneBot v11 协议和数据结构
|
||||||
- 支持图片、视频等媒体消息
|
- 支持图片、视频等媒体消息
|
||||||
|
- 支持合并转发消息
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
@@ -35,6 +38,236 @@ CROSS_PLATFORM_CHANNEL = "neobot_cross_platform"
|
|||||||
ENABLE_CROSS_PLATFORM = True
|
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:
|
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]"
|
return f"[Discord]"
|
||||||
elif platform == "qq":
|
elif platform == "qq":
|
||||||
group_id = int(identifier)
|
group_id = int(identifier)
|
||||||
return f"[QQ {group_id}]"
|
return f"[PAW qq]"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +297,7 @@ async def format_discord_to_qq_content(
|
|||||||
discord_discriminator: str,
|
discord_discriminator: str,
|
||||||
content: str,
|
content: str,
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None
|
||||||
) -> tuple[str, List[str]]:
|
) -> tuple[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
将 Discord 消息格式化为 QQ 消息格式
|
将 Discord 消息格式化为 QQ 消息格式
|
||||||
@@ -93,7 +326,18 @@ async def format_discord_to_qq_content(
|
|||||||
else:
|
else:
|
||||||
full_message = message_header
|
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(
|
async def format_qq_to_discord_content(
|
||||||
@@ -102,10 +346,10 @@ async def format_qq_to_discord_content(
|
|||||||
group_name: str,
|
group_name: str,
|
||||||
group_id: int,
|
group_id: int,
|
||||||
content: str,
|
content: str,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None
|
||||||
) -> tuple[str, List[str]]:
|
) -> tuple[str, List[dict], dict]:
|
||||||
"""
|
"""
|
||||||
将 QQ 消息格式化为 Discord 消息格式
|
将 QQ 消息格式化为 Discord 消息格式(Embed 卡片)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
qq_nickname: QQ 昵称
|
qq_nickname: QQ 昵称
|
||||||
@@ -116,26 +360,53 @@ async def format_qq_to_discord_content(
|
|||||||
attachments: 附件列表
|
attachments: 附件列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
格式化后的消息内容和图片列表
|
格式化后的消息内容、附件列表和 Embed 字典
|
||||||
"""
|
"""
|
||||||
platform_info = get_platform_info("qq", group_id)
|
platform_info = get_platform_info("qq", group_id)
|
||||||
|
|
||||||
# 构建消息头(简化版,只显示名字)
|
# 构建 Embed 卡片
|
||||||
message_header = f"{platform_info} {qq_nickname}:"
|
embed = {
|
||||||
|
"type": "rich",
|
||||||
|
"color": 0x5865F2, # Discord 蓝色
|
||||||
|
"author": {
|
||||||
|
"name": f"{platform_info} {qq_nickname}",
|
||||||
|
"icon_url": f"https://q1.qlogo.cn/g?b=qq&nk={qq_user_id}&s=640"
|
||||||
|
},
|
||||||
|
"description": content if content else "",
|
||||||
|
"timestamp": None,
|
||||||
|
"footer": {
|
||||||
|
"text": f"来自 QQPAW"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# 构建消息体
|
# 如果有附件,添加到 description
|
||||||
message_body = content if content else ""
|
if attachments:
|
||||||
|
image_urls = []
|
||||||
# 组合完整消息(移除分隔符)
|
other_urls = []
|
||||||
if message_body:
|
for att in attachments:
|
||||||
full_message = f"{message_header} {message_body}"
|
if att.get("url", "").lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')):
|
||||||
|
image_urls.append(att.get("url"))
|
||||||
else:
|
else:
|
||||||
full_message = message_header
|
other_urls.append(att.get("url"))
|
||||||
|
|
||||||
return full_message, attachments or []
|
if image_urls:
|
||||||
|
embed["description"] = f"{content}\n\n{chr(10).join(image_urls[:3])}" if content else chr(10).join(image_urls[:3])
|
||||||
|
if len(image_urls) > 3:
|
||||||
|
embed["description"] += f"\n...还有 {len(image_urls) - 3} 张图片"
|
||||||
|
|
||||||
|
# 附加文件列表
|
||||||
|
if other_urls:
|
||||||
|
file_list = "\n".join([f"📄 {os.path.basename(u.split('?')[0])}" for u in other_urls[:5]])
|
||||||
|
embed["description"] += f"\n\n**附加文件:**\n{file_list}"
|
||||||
|
if len(other_urls) > 5:
|
||||||
|
embed["description"] += f"\n...还有 {len(other_urls) - 5} 个文件"
|
||||||
|
|
||||||
|
# 对于合并转发消息,content 为空,只发送 embed
|
||||||
|
# 对于普通消息,content 也为空,只发送 embed
|
||||||
|
return "", attachments or [], embed
|
||||||
|
|
||||||
|
|
||||||
async def send_to_discord(channel_id: int, content: str, attachments: List[str] = None):
|
async def send_to_discord(channel_id: int, content: str, attachments: List[dict] = None, embed: dict = None):
|
||||||
"""
|
"""
|
||||||
发送消息到 Discord 频道
|
发送消息到 Discord 频道
|
||||||
|
|
||||||
@@ -145,14 +416,16 @@ async def send_to_discord(channel_id: int, content: str, attachments: List[str]
|
|||||||
Args:
|
Args:
|
||||||
channel_id: Discord 频道 ID
|
channel_id: Discord 频道 ID
|
||||||
content: 消息内容
|
content: 消息内容
|
||||||
attachments: 附件 URL 列表
|
attachments: 附件列表,每个元素为 {"url": str, "filename": str}
|
||||||
|
embed: Discord Embed 字典
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
publish_data = {
|
publish_data = {
|
||||||
"type": "send_message",
|
"type": "send_message",
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"content": content,
|
"content": content,
|
||||||
"attachments": attachments or []
|
"attachments": attachments or [],
|
||||||
|
"embed": embed
|
||||||
}
|
}
|
||||||
await redis_manager.redis.publish("neobot_discord_send", json.dumps(publish_data))
|
await redis_manager.redis.publish("neobot_discord_send", json.dumps(publish_data))
|
||||||
logger.info(f"[CrossPlatform] 消息已发布到 Redis 供 Discord 适配器发送: {channel_id}")
|
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}")
|
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 群
|
发送消息到 QQ 群
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
group_id: QQ 群 ID
|
group_id: QQ 群 ID
|
||||||
content: 消息内容
|
content: 消息内容
|
||||||
attachments: 附件 URL 列表
|
attachments: 附件列表,每个元素为 {"url": str, "filename": str}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from core.managers.bot_manager import bot_manager
|
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:
|
if content:
|
||||||
full_message.append(MessageSegment.text(content))
|
full_message.append(MessageSegment.text(content))
|
||||||
for attachment in attachments:
|
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}")
|
logger.debug(f"[CrossPlatform] 准备发送消息到 QQ 群 {group_id}: {full_message}")
|
||||||
# 一次性发送
|
# 一次性发送
|
||||||
@@ -219,7 +496,7 @@ async def forward_discord_to_qq(
|
|||||||
discord_discriminator: str,
|
discord_discriminator: str,
|
||||||
content: str,
|
content: str,
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
将 Discord 消息转发到所有映射的 QQ 群
|
将 Discord 消息转发到所有映射的 QQ 群
|
||||||
@@ -259,7 +536,7 @@ async def forward_qq_to_discord(
|
|||||||
group_name: str,
|
group_name: str,
|
||||||
group_id: int,
|
group_id: int,
|
||||||
content: str,
|
content: str,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
将 QQ 消息转发到所有映射的 Discord 频道
|
将 QQ 消息转发到所有映射的 Discord 频道
|
||||||
@@ -283,7 +560,7 @@ async def forward_qq_to_discord(
|
|||||||
return
|
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_nickname,
|
||||||
qq_user_id,
|
qq_user_id,
|
||||||
group_name,
|
group_name,
|
||||||
@@ -294,7 +571,7 @@ async def forward_qq_to_discord(
|
|||||||
|
|
||||||
# 发送到所有映射的 Discord 频道
|
# 发送到所有映射的 Discord 频道
|
||||||
for channel_id in target_channels:
|
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}")
|
logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}")
|
||||||
|
|
||||||
@@ -325,7 +602,8 @@ async def handle_discord_message(
|
|||||||
discriminator: str,
|
discriminator: str,
|
||||||
content: str,
|
content: str,
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None,
|
||||||
|
embed: dict = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
处理 Discord 消息并转发
|
处理 Discord 消息并转发
|
||||||
@@ -336,6 +614,7 @@ async def handle_discord_message(
|
|||||||
content: 消息内容
|
content: 消息内容
|
||||||
channel_id: Discord 频道 ID
|
channel_id: Discord 频道 ID
|
||||||
attachments: 附件列表
|
attachments: 附件列表
|
||||||
|
embed: Discord Embed 字典
|
||||||
"""
|
"""
|
||||||
if not ENABLE_CROSS_PLATFORM:
|
if not ENABLE_CROSS_PLATFORM:
|
||||||
return
|
return
|
||||||
@@ -352,7 +631,7 @@ async def handle_qq_message(
|
|||||||
group_name: str,
|
group_name: str,
|
||||||
group_id: int,
|
group_id: int,
|
||||||
content: str,
|
content: str,
|
||||||
attachments: List[str] = None
|
attachments: List[dict] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
处理 QQ 消息并转发
|
处理 QQ 消息并转发
|
||||||
@@ -398,18 +677,37 @@ async def handle_qq_group_message(event: GroupMessageEvent):
|
|||||||
attachments = []
|
attachments = []
|
||||||
|
|
||||||
if isinstance(event.message, list):
|
if isinstance(event.message, list):
|
||||||
|
# 检查是否是合并转发消息
|
||||||
|
has_forward_node = any(isinstance(seg, MessageSegment) and seg.type == "node" for seg in event.message)
|
||||||
|
|
||||||
|
if has_forward_node:
|
||||||
|
# 解析合并转发消息
|
||||||
|
forward_nodes = [seg for seg in event.message if isinstance(seg, MessageSegment) and seg.type == "node"]
|
||||||
|
# 将 MessageSegment 转换为字典格式
|
||||||
|
forward_nodes_dict = [{"type": seg.type, "data": seg.data} for seg in forward_nodes]
|
||||||
|
content, attachments = await parse_forward_nodes(forward_nodes_dict)
|
||||||
|
else:
|
||||||
|
# 普通消息
|
||||||
for segment in event.message:
|
for segment in event.message:
|
||||||
if isinstance(segment, MessageSegment):
|
if isinstance(segment, MessageSegment):
|
||||||
if segment.type == "text":
|
if segment.type == "text":
|
||||||
content += segment.data.get("text", "")
|
content += segment.data.get("text", "")
|
||||||
elif segment.type == "image":
|
elif segment.type == "image":
|
||||||
file_url = segment.data.get("url") or segment.data.get("file")
|
file_url = segment.data.get("url") or segment.data.get("file")
|
||||||
|
file_name = segment.data.get("file", "")
|
||||||
if file_url:
|
if file_url:
|
||||||
attachments.append(str(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":
|
elif segment.type == "video":
|
||||||
file_url = segment.data.get("url") or segment.data.get("file")
|
file_url = segment.data.get("url") or segment.data.get("file")
|
||||||
|
file_name = segment.data.get("file", "")
|
||||||
if file_url:
|
if file_url:
|
||||||
attachments.append(str(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":
|
elif segment.type == "at":
|
||||||
qq_id = segment.data.get("qq")
|
qq_id = segment.data.get("qq")
|
||||||
if qq_id and qq_id != "all":
|
if qq_id and qq_id != "all":
|
||||||
@@ -473,8 +771,12 @@ async def handle_discord_message_event(event: Any):
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
if re.match(url_pattern, line):
|
if re.match(url_pattern, line):
|
||||||
# 这是附件 URL
|
# 这是附件 URL
|
||||||
if line not in attachments:
|
attachment_url = line
|
||||||
attachments.append(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:
|
else:
|
||||||
# 这是普通文本内容
|
# 这是普通文本内容
|
||||||
if line:
|
if line:
|
||||||
@@ -490,12 +792,20 @@ async def handle_discord_message_event(event: Any):
|
|||||||
pass # 已经在 raw_message 中
|
pass # 已经在 raw_message 中
|
||||||
elif segment.type == "image":
|
elif segment.type == "image":
|
||||||
file_url = segment.data.get("url") or segment.data.get("file")
|
file_url = segment.data.get("url") or segment.data.get("file")
|
||||||
if file_url and str(file_url) not in attachments:
|
file_name = segment.data.get("file", "")
|
||||||
attachments.append(str(file_url))
|
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":
|
elif segment.type == "video":
|
||||||
file_url = segment.data.get("url") or segment.data.get("file")
|
file_url = segment.data.get("url") or segment.data.get("file")
|
||||||
if file_url and str(file_url) not in attachments:
|
file_name = segment.data.get("file", "")
|
||||||
attachments.append(str(file_url))
|
if file_url:
|
||||||
|
file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video"
|
||||||
|
attachment_item = {"url": str(file_url), "filename": file_name}
|
||||||
|
if attachment_item not in attachments:
|
||||||
|
attachments.append(attachment_item)
|
||||||
|
|
||||||
# 获取用户信息
|
# 获取用户信息
|
||||||
discord_username = getattr(event, 'discord_username', 'Unknown')
|
discord_username = getattr(event, 'discord_username', 'Unknown')
|
||||||
@@ -507,7 +817,8 @@ async def handle_discord_message_event(event: Any):
|
|||||||
discriminator=discord_discriminator,
|
discriminator=discord_discriminator,
|
||||||
content=content,
|
content=content,
|
||||||
channel_id=discord_channel_id,
|
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_name=message_data.get("group_name", ""),
|
||||||
group_id=message_data.get("group_id", 0),
|
group_id=message_data.get("group_id", 0),
|
||||||
content=message_data.get("content", ""),
|
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:
|
except json.JSONDecodeError as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user