feat: 添加Discord适配器与跨平台消息互通功能

新增Discord适配器支持,实现Discord与QQ之间的消息互通
添加通用数据模型用于跨平台消息转换
扩展配置系统以支持Discord和日志配置
重构日志系统以使用配置中的日志级别
在反向WebSocket管理器中注册Bot实例
更新主程序以支持Discord客户端启动
添加测试脚本验证核心功能
This commit is contained in:
2026-03-15 13:36:17 +08:00
parent 2a6e9b8f89
commit f868553342
12 changed files with 1490 additions and 13 deletions

703
plugins/cross_platform.py Normal file
View File

@@ -0,0 +1,703 @@
# -*- coding: utf-8 -*-
"""
跨平台消息互通插件
功能:
- Discord 频道与 QQ 群之间的消息互通
- 在消息中自动标注来源平台和子频道/群组 ID
- 支持 OneBot v11 协议和数据结构
- 支持图片、视频等媒体消息
"""
import asyncio
import json
import re
import time
from typing import Dict, List, Optional, Any
from core.managers.command_manager import matcher
from models.events.message import GroupMessageEvent, PrivateMessageEvent, MessageEvent
from models.message import MessageSegment
from core.permission import Permission
from core.utils.logger import logger
from core.managers.redis_manager import redis_manager
# --- 配置 ---
# 跨平台映射配置
# 格式: {discord_channel_id: {"qq_group_id": qq_group_id, "name": "显示名称"}}
CROSS_PLATFORM_MAP: Dict[int, Dict[str, Any]] = {
# 示例配置:
# 123456789012345678: {"qq_group_id": 123456789, "name": "主群"},
# 987654321098765432: {"qq_group_id": 987654321, "name": "测试群"},
}
# Redis 通道名称
CROSS_PLATFORM_CHANNEL = "neobot_cross_platform"
# 是否启用跨平台转发
ENABLE_CROSS_PLATFORM = True
def get_platform_info(platform: str, identifier: Any) -> str:
"""
获取平台信息字符串,用于在消息中标注来源
Args:
platform: 平台名称 ('discord''qq')
identifier: 频道 ID 或群组 ID
Returns:
格式化的平台信息字符串
"""
if platform == "discord":
channel_id = int(identifier)
if channel_id in CROSS_PLATFORM_MAP:
group_info = 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"[QQ {group_id}]"
return ""
async def format_discord_to_qq_content(
discord_username: str,
discord_discriminator: str,
content: str,
channel_id: int,
attachments: List[str] = None
) -> tuple[str, List[str]]:
"""
将 Discord 消息格式化为 QQ 消息格式
Args:
discord_username: Discord 用户名
discord_discriminator: Discord discriminator (如 #1234)
content: 消息内容
channel_id: Discord 频道 ID
attachments: 附件列表
Returns:
格式化后的消息内容和图片列表
"""
platform_info = get_platform_info("discord", channel_id)
# 构建消息头(简化版,只显示名字)
message_header = f"{platform_info}\n {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
return full_message, attachments or []
async def format_qq_to_discord_content(
qq_nickname: str,
qq_user_id: int,
group_name: str,
group_id: int,
content: str,
attachments: List[str] = None
) -> tuple[str, List[str]]:
"""
将 QQ 消息格式化为 Discord 消息格式
Args:
qq_nickname: QQ 昵称
qq_user_id: QQ 用户 ID
group_name: 群名称
group_id: QQ 群 ID
content: 消息内容
attachments: 附件列表
Returns:
格式化后的消息内容和图片列表
"""
platform_info = get_platform_info("qq", group_id)
# 构建消息头(简化版,只显示名字)
message_header = f"{platform_info} {qq_nickname}:"
# 构建消息体
message_body = content if content else ""
# 组合完整消息(移除分隔符)
if message_body:
full_message = f"{message_header} {message_body}"
else:
full_message = message_header
return full_message, attachments or []
async def send_to_discord(channel_id: int, content: str, attachments: List[str] = None):
"""
发送消息到 Discord 频道
通过 Redis 发布消息,由 Discord 适配器接收并发送
这样可以避免跨模块导入实例的问题
Args:
channel_id: Discord 频道 ID
content: 消息内容
attachments: 附件 URL 列表
"""
try:
publish_data = {
"type": "send_message",
"channel_id": channel_id,
"content": content,
"attachments": attachments or []
}
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[str] = None):
"""
发送消息到 QQ 群
Args:
group_id: QQ 群 ID
content: 消息内容
attachments: 附件 URL 列表
"""
try:
from core.managers.bot_manager import bot_manager
from models.message import MessageSegment
# 获取所有 QQ 机器人实例
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
# 发送消息(如果有附件,使用 OneBot 的图片格式)
if attachments:
# 构建完整消息:文本 + 图片
from models.message import MessageSegment
full_message = []
if content:
full_message.append(MessageSegment.text(content))
for attachment in attachments:
full_message.append(MessageSegment.image(attachment, 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[str] = None
):
"""
将 Discord 消息转发到所有映射的 QQ 群
Args:
discord_username: Discord 用户名
discord_discriminator: Discord discriminator
content: 消息内容
channel_id: Discord 频道 ID
attachments: 附件列表
"""
if channel_id not in CROSS_PLATFORM_MAP:
logger.warning(f"[CrossPlatform] 未找到 Discord 频道 {channel_id} 的映射配置")
return
group_info = 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
)
# 发送到 QQ
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[str] = None
):
"""
将 QQ 消息转发到所有映射的 Discord 频道
Args:
qq_nickname: QQ 昵称
qq_user_id: QQ 用户 ID
group_name: 群名称
group_id: QQ 群 ID
content: 消息内容
attachments: 附件列表
"""
# 查找映射的 Discord 频道
target_channels = []
for discord_channel_id, info in 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 = await format_qq_to_discord_content(
qq_nickname,
qq_user_id,
group_name,
group_id,
content,
attachments
)
# 发送到所有映射的 Discord 频道
for channel_id in target_channels:
await send_to_discord(channel_id, formatted_content, image_list)
logger.success(f"[CrossPlatform] QQ 群 {group_id} -> Discord 频道 {target_channels}")
async def publish_to_redis(platform: str, data: dict):
"""
通过 Redis 发布跨平台消息
Args:
platform: 平台名称
data: 消息数据
"""
try:
if redis_manager.redis:
publish_data = {
"platform": platform,
"data": data,
"timestamp": int(__import__('time').time())
}
await redis_manager.redis.publish(CROSS_PLATFORM_CHANNEL, json.dumps(publish_data))
logger.debug(f"[CrossPlatform] 已通过 Redis 发布消息: platform={platform}")
except Exception as e:
logger.error(f"[CrossPlatform] Redis 发布失败: {e}")
async def handle_discord_message(
username: str,
discriminator: str,
content: str,
channel_id: int,
attachments: List[str] = None
):
"""
处理 Discord 消息并转发
Args:
username: Discord 用户名
discriminator: Discord discriminator
content: 消息内容
channel_id: Discord 频道 ID
attachments: 附件列表
"""
if not ENABLE_CROSS_PLATFORM:
return
logger.info(f"[CrossPlatform] 收到 Discord 消息: {username}#{discriminator} in {channel_id}")
# 转发到映射的 QQ 群
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[str] = None
):
"""
处理 QQ 消息并转发
Args:
nickname: QQ 昵称
user_id: QQ 用户 ID
group_name: 群名称
group_id: QQ 群 ID
content: 消息内容
attachments: 附件列表
"""
if not ENABLE_CROSS_PLATFORM:
return
logger.info(f"[CrossPlatform] 收到 QQ 消息: {nickname} ({user_id}) in {group_name}({group_id})")
# 转发到映射的 Discord 频道
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 ENABLE_CROSS_PLATFORM:
return
# 检查是否是映射的群组
group_id = event.group_id
mapped_channel = None
for discord_channel_id, info in 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):
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
elif isinstance(event.message, str):
content = event.message
# 清理多余空白
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 ENABLE_CROSS_PLATFORM:
return
# 检查事件是否包含 Discord 特定信息
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 = event.raw_message or ""
attachments = []
# 从 raw_message 中提取附件 URLDiscord 附件已添加到 raw_message
import re
url_pattern = r'https?://[^\s<>"]+|www\.\S+'
raw_message_lines = content.split('\n')
content_lines = []
for line in raw_message_lines:
line = line.strip()
if re.match(url_pattern, line):
# 这是附件 URL
if line not in attachments:
attachments.append(line)
else:
# 这是普通文本内容
if line:
content_lines.append(line)
content = '\n'.join(content_lines).strip()
# 从 message 列表中提取(备用方案)
if hasattr(event, 'message') and isinstance(event.message, list):
for segment in event.message:
if isinstance(segment, MessageSegment):
if segment.type == "text":
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))
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))
# 获取用户信息
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
)
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(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":
# 从 Discord 转发到 QQ
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":
# 从 QQ 转发到 Discord
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", [])
)
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 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] 跨平台消息订阅已停止")
async def reload_config():
"""
重新加载配置
"""
global CROSS_PLATFORM_MAP, ENABLE_CROSS_PLATFORM
try:
import os
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 = tomllib.load(f)
cross_platform_config = config.get("cross_platform", {})
ENABLE_CROSS_PLATFORM = cross_platform_config.get("enabled", True)
# 重新加载映射配置(支持两种格式)
mappings = cross_platform_config.get("mappings", {})
CROSS_PLATFORM_MAP = {}
# 格式1: [cross_platform.mappings.123456789012345678] 子表形式
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])
CROSS_PLATFORM_MAP[discord_id] = {
"qq_group_id": int(value.get("qq_group_id", 0)),
"name": value.get("name", "")
}
except (ValueError, AttributeError):
continue
# 格式2: 旧的字典形式(向后兼容)
if not CROSS_PLATFORM_MAP:
for key, value in mappings.items():
if isinstance(key, str) and key.isdigit():
CROSS_PLATFORM_MAP[int(key)] = {
"qq_group_id": int(value.get("qq_group_id", 0)),
"name": value.get("name", "")
}
logger.success(f"[CrossPlatform] 配置已重新加载: {len(CROSS_PLATFORM_MAP)} 个映射")
except Exception as e:
logger.error(f"[CrossPlatform] 重新加载配置失败: {e}")
# 插件加载时自动启动和加载配置
import asyncio
try:
asyncio.create_task(reload_config())
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}")
# 命令处理器
@matcher.command("cross_config", "跨平台配置", permission=Permission.ADMIN)
async def cross_config_command(event: MessageEvent):
"""
查看跨平台配置
"""
if not ENABLE_CROSS_PLATFORM:
await event.reply("跨平台功能已禁用")
return
config_lines = ["=== 跨平台映射配置 ==="]
if not CROSS_PLATFORM_MAP:
config_lines.append("当前没有配置任何映射")
else:
for discord_id, info in 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 reload_config()
await event.reply("跨平台配置已重载")
# 清理函数
def cleanup():
"""清理资源"""
asyncio.create_task(stop_cross_platform_subscription())