feat: 添加带有消息处理和路由功能的NEW_napcat_adapter插件

- 为NEW_napcat_adapter插件实现了核心模块,包括消息处理、事件处理和路由。
- 创建了MessageHandler、MetaEventHandler和NoticeHandler来处理收到的消息和事件。
- 开发了SendHandler,用于向Napcat发送回消息。
引入了StreamRouter来管理多个聊天流,确保消息的顺序和高效处理。
- 增加了对各种消息类型和格式的支持,包括文本、图像和通知。
- 建立了一个用于监控和调试的日志系统。
This commit is contained in:
Windpicker-owo
2025-11-24 13:24:55 +08:00
parent b08c70dfa6
commit 36fce6ca98
28 changed files with 3041 additions and 824 deletions

View File

@@ -1,15 +1,22 @@
"""
MessageEnvelope 转换器
MessageEnvelope converter between mofox_bus schema and internal message structures.
将 mofox_bus MessageEnvelope 转换为 MoFox Bot 内部使用的消息格式
"""
- 优先处理 maim_message 风格message_info + message_segment
- 兼容旧版 content/sender/channel 结构,方便逐步迁移。
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from mofox_bus import MessageEnvelope, BaseMessageInfo, FormatInfo, GroupInfo, MessageBase, Seg, UserInfo
from mofox_bus import (
BaseMessageInfo,
MessageBase,
MessageEnvelope,
Seg,
UserInfo,
GroupInfo,
)
from src.common.logger import get_logger
@@ -17,151 +24,221 @@ logger = get_logger("envelope_converter")
class EnvelopeConverter:
"""MessageEnvelope 到内部消息格式的转换器"""
"""MessageEnvelope <-> MessageBase converter."""
@staticmethod
def to_message_base(envelope: MessageEnvelope) -> MessageBase:
"""
MessageEnvelope 转换为 MessageBase
Args:
envelope: 统一的消息信封
Returns:
MessageBase: 内部消息格式
Convert MessageEnvelope to MessageBase.
"""
try:
# 提取基本信息
platform = envelope["platform"]
channel = envelope["channel"]
sender = envelope["sender"]
content = envelope["content"]
# 创建 UserInfo
user_info = UserInfo(
user_id=sender["user_id"],
user_nickname=sender.get("display_name", sender["user_id"]),
user_avatar=sender.get("avatar_url"),
)
# 创建 GroupInfo (如果是群组消息)
group_info: Optional[GroupInfo] = None
if channel["channel_type"] in ("group", "supergroup", "room"):
group_info = GroupInfo(
group_id=channel["channel_id"],
group_name=channel.get("title", channel["channel_id"]),
)
# 创建 BaseMessageInfo
message_info = BaseMessageInfo(
platform=platform,
chat_type="group" if group_info else "private",
message_id=envelope["id"],
user_info=user_info,
group_info=group_info,
timestamp=envelope["timestamp_ms"] / 1000.0, # 转换为秒
)
# 转换 Content 为 Seg 列表
segments = EnvelopeConverter._content_to_segments(content)
# 创建 MessageBase
message_base = MessageBase(
# 优先使用 maim_message 样式字段
info_payload = envelope.get("message_info") or {}
seg_payload = envelope.get("message_segment") or envelope.get("message_chain")
if info_payload:
message_info = BaseMessageInfo.from_dict(info_payload)
else:
message_info = EnvelopeConverter._build_info_from_legacy(envelope)
if seg_payload is None:
seg_list = EnvelopeConverter._content_to_segments(envelope.get("content"))
seg_payload = seg_list
message_segment = EnvelopeConverter._ensure_seg(seg_payload)
raw_message = envelope.get("raw_message") or envelope.get("raw_platform_message")
return MessageBase(
message_info=message_info,
message=segments,
message_segment=message_segment,
raw_message=raw_message,
)
# 保存原始 envelope 到 raw 字段
if hasattr(message_base, "raw"):
message_base.raw = envelope
return message_base
except Exception as e:
logger.error(f"转换 MessageEnvelope 失败: {e}", exc_info=True)
raise
@staticmethod
def _content_to_segments(content: Dict[str, Any]) -> List[Seg]:
def _build_info_from_legacy(envelope: MessageEnvelope) -> BaseMessageInfo:
"""将 legacy 字段映射为 BaseMessageInfo。"""
platform = envelope.get("platform")
channel = envelope.get("channel") or {}
sender = envelope.get("sender") or {}
message_id = envelope.get("id") or envelope.get("message_id")
timestamp_ms = envelope.get("timestamp_ms")
time_value = (timestamp_ms / 1000.0) if timestamp_ms is not None else None
group_info: Optional[GroupInfo] = None
channel_type = channel.get("channel_type")
if channel_type in ("group", "supergroup", "room"):
group_info = GroupInfo(
platform=platform,
group_id=channel.get("channel_id"),
group_name=channel.get("title"),
)
user_info: Optional[UserInfo] = None
if sender:
user_info = UserInfo(
platform=platform,
user_id=str(sender.get("user_id")) if sender.get("user_id") is not None else None,
user_nickname=sender.get("display_name") or sender.get("user_nickname"),
user_avatar=sender.get("avatar_url"),
)
return BaseMessageInfo(
platform=platform,
message_id=message_id,
time=time_value,
group_info=group_info,
user_info=user_info,
additional_config=envelope.get("metadata"),
)
@staticmethod
def _ensure_seg(payload: Any) -> Seg:
"""将任意 payload 转为 Seg dataclass。"""
if isinstance(payload, Seg):
return payload
if isinstance(payload, list):
# 直接传入 Seg 列表或 seglist data
return Seg(type="seglist", data=[EnvelopeConverter._ensure_seg(item) for item in payload])
if isinstance(payload, dict):
seg_type = payload.get("type") or "text"
data = payload.get("data")
if seg_type == "seglist" and isinstance(data, list):
data = [EnvelopeConverter._ensure_seg(item) for item in data]
return Seg(type=seg_type, data=data)
# 兜底:转成文本片段
return Seg(type="text", data="" if payload is None else str(payload))
@staticmethod
def _flatten_segments(seg: Seg) -> List[Seg]:
"""将 Seg/seglist 打平成列表,便于旧 content 转换。"""
if seg.type == "seglist" and isinstance(seg.data, list):
return [item if isinstance(item, Seg) else EnvelopeConverter._ensure_seg(item) for item in seg.data]
return [seg]
@staticmethod
def _content_to_segments(content: Any) -> List[Seg]:
"""
将 Content 转换为 Seg 列表
Args:
content: 消息内容
Returns:
List[Seg]: 消息段列表
Convert legacy Content (type/data/metadata) to a flat list of Seg.
"""
segments: List[Seg] = []
content_type = content.get("type")
if content_type == "text":
# 文本消息
text = content.get("text", "")
segments.append(Seg.text(text))
elif content_type == "image":
# 图片消息
url = content.get("url", "")
file_id = content.get("file_id")
segments.append(Seg.image(url if url else file_id))
elif content_type == "audio":
# 音频消息
url = content.get("url", "")
file_id = content.get("file_id")
segments.append(Seg.record(url if url else file_id))
elif content_type == "video":
# 视频消息
url = content.get("url", "")
file_id = content.get("file_id")
segments.append(Seg.video(url if url else file_id))
elif content_type == "file":
# 文件消息
url = content.get("url", "")
file_name = content.get("file_name", "file")
# 使用 text 表示文件(或者可以自定义一个 file seg type
segments.append(Seg.text(f"[文件: {file_name}]"))
elif content_type == "command":
# 命令消息
name = content.get("name", "")
args = content.get("args", {})
# 重构为文本格式
cmd_text = f"/{name}"
if args:
cmd_text += " " + " ".join(f"{k}={v}" for k, v in args.items())
segments.append(Seg.text(cmd_text))
elif content_type == "event":
# 事件消息 - 转换为文本表示
event_type = content.get("event_type", "unknown")
segments.append(Seg.text(f"[事件: {event_type}]"))
elif content_type == "system":
# 系统消息
text = content.get("text", "")
segments.append(Seg.text(f"[系统] {text}"))
else:
# 未知类型 - 转换为文本
def _walk(node: Any) -> None:
if node is None:
return
if isinstance(node, list):
for item in node:
_walk(item)
return
if not isinstance(node, dict):
logger.warning("未知的 content 节点类型: %s", type(node))
return
content_type = node.get("type")
data = node.get("data")
metadata = node.get("metadata") or {}
if content_type == "collection":
items = data if isinstance(data, list) else node.get("items", [])
for item in items:
_walk(item)
return
if content_type in ("text", "at"):
subtype = metadata.get("subtype") or ("at" if content_type == "at" else None)
text = "" if data is None else str(data)
if subtype in ("at", "mention"):
user_info = metadata.get("user") or {}
seg_data: Dict[str, Any] = {
"user_id": user_info.get("id") or user_info.get("user_id"),
"user_name": user_info.get("name") or user_info.get("display_name"),
"text": text,
"raw": user_info.get("raw") or user_info if user_info else None,
}
if any(v is not None for v in seg_data.values()):
segments.append(Seg(type="at", data=seg_data))
else:
segments.append(Seg(type="at", data=text))
else:
segments.append(Seg(type="text", data=text))
return
if content_type == "image":
url = ""
if isinstance(data, dict):
url = data.get("url") or data.get("file") or data.get("file_id") or ""
elif data is not None:
url = str(data)
segments.append(Seg(type="image", data=url))
return
if content_type == "audio":
url = ""
if isinstance(data, dict):
url = data.get("url") or data.get("file") or data.get("file_id") or ""
elif data is not None:
url = str(data)
segments.append(Seg(type="record", data=url))
return
if content_type == "video":
url = ""
if isinstance(data, dict):
url = data.get("url") or data.get("file") or data.get("file_id") or ""
elif data is not None:
url = str(data)
segments.append(Seg(type="video", data=url))
return
if content_type == "file":
file_name = ""
if isinstance(data, dict):
file_name = data.get("file_name") or data.get("name") or ""
text = file_name or "[file]"
segments.append(Seg(type="text", data=text))
return
if content_type == "command":
name = ""
args: Dict[str, Any] = {}
if isinstance(data, dict):
name = data.get("name", "")
args = data.get("args", {}) or {}
else:
name = str(data or "")
cmd_text = f"/{name}" if name else "/command"
if args:
cmd_text += " " + " ".join(f"{k}={v}" for k, v in args.items())
segments.append(Seg(type="text", data=cmd_text))
return
if content_type == "event":
event_type = ""
if isinstance(data, dict):
event_type = data.get("event_type", "")
else:
event_type = str(data or "")
segments.append(Seg(type="text", data=f"[事件: {event_type or 'unknown'}]"))
return
if content_type == "system":
text = "" if data is None else str(data)
segments.append(Seg(type="text", data=f"[系统] {text}"))
return
logger.warning(f"未知的消息类型: {content_type}")
segments.append(Seg.text(f"[未知消息类型: {content_type}]"))
segments.append(Seg(type="text", data=f"[未知消息类型: {content_type}]"))
_walk(content)
return segments
@staticmethod
def to_legacy_dict(envelope: MessageEnvelope) -> Dict[str, Any]:
"""
MessageEnvelope 转换为旧版字典格式(用于向后兼容)
Args:
envelope: 统一的消息信封
Returns:
Dict[str, Any]: 旧版消息字典
Convert MessageEnvelope to legacy dict for backward compatibility.
"""
message_base = EnvelopeConverter.to_message_base(envelope)
return message_base.to_dict()
@@ -169,61 +246,45 @@ class EnvelopeConverter:
@staticmethod
def from_message_base(message: MessageBase, direction: str = "outgoing") -> MessageEnvelope:
"""
MessageBase 转换为 MessageEnvelope (反向转换)
Args:
message: 内部消息格式
direction: 消息方向 ("incoming""outgoing")
Returns:
MessageEnvelope: 统一的消息信封
Convert MessageBase to MessageEnvelope (maim_message style preferred).
"""
try:
message_info = message.message_info
user_info = message_info.user_info
group_info = message_info.group_info
# 创建 SenderInfo
sender = {
"user_id": user_info.user_id,
"role": "assistant" if direction == "outgoing" else "user",
}
if user_info.user_nickname:
sender["display_name"] = user_info.user_nickname
if user_info.user_avatar:
sender["avatar_url"] = user_info.user_avatar
# 创建 ChannelInfo
if group_info:
channel = {
"channel_id": group_info.group_id,
"channel_type": "group",
}
if group_info.group_name:
channel["title"] = group_info.group_name
else:
channel = {
"channel_id": user_info.user_id,
"channel_type": "private",
}
# 转换 segments 为 Content
content = EnvelopeConverter._segments_to_content(message.message)
# 创建 MessageEnvelope
info_dict = message.message_info.to_dict()
seg_dict = message.message_segment.to_dict()
envelope: MessageEnvelope = {
"id": message_info.message_id,
"direction": direction,
"platform": message_info.platform,
"timestamp_ms": int(message_info.timestamp * 1000),
"channel": channel,
"sender": sender,
"content": content,
"conversation_id": group_info.group_id if group_info else user_info.user_id,
"message_info": info_dict,
"message_segment": seg_dict,
"platform": info_dict.get("platform"),
"message_id": info_dict.get("message_id"),
"schema_version": 1,
}
if message.message_info.time is not None:
envelope["timestamp_ms"] = int(message.message_info.time * 1000)
if message.raw_message is not None:
envelope["raw_message"] = message.raw_message
# legacy 补充,方便老代码继续工作
segments = EnvelopeConverter._flatten_segments(message.message_segment)
envelope["content"] = EnvelopeConverter._segments_to_content(segments)
if message.message_info.user_info:
envelope["sender"] = {
"user_id": message.message_info.user_info.user_id,
"role": "assistant" if direction == "outgoing" else "user",
"display_name": message.message_info.user_info.user_nickname,
"avatar_url": getattr(message.message_info.user_info, "user_avatar", None),
}
if message.message_info.group_info:
envelope["channel"] = {
"channel_id": message.message_info.group_info.group_id,
"channel_type": "group",
"title": message.message_info.group_info.group_name,
}
return envelope
except Exception as e:
logger.error(f"转换 MessageBase 失败: {e}", exc_info=True)
raise
@@ -231,45 +292,50 @@ class EnvelopeConverter:
@staticmethod
def _segments_to_content(segments: List[Seg]) -> Dict[str, Any]:
"""
将 Seg 列表转换为 Content
Args:
segments: 消息段列表
Returns:
Dict[str, Any]: 消息内容
Convert Seg list to legacy Content (type/data/metadata).
"""
if not segments:
return {"type": "text", "text": ""}
# 简化处理:如果有多个段,合并为文本
return {"type": "text", "data": ""}
def _seg_to_content(seg: Seg) -> Dict[str, Any]:
data = seg.data
if seg.type == "text":
return {"type": "text", "data": data}
if seg.type == "at":
content: Dict[str, Any] = {"type": "text", "data": ""}
metadata: Dict[str, Any] = {"subtype": "at"}
if isinstance(data, dict):
content["data"] = data.get("text", "")
user = {
"id": data.get("user_id"),
"name": data.get("user_name"),
"raw": data.get("raw"),
}
if any(v is not None for v in user.values()):
metadata["user"] = user
else:
content["data"] = data
if metadata:
content["metadata"] = metadata
return content
if seg.type == "image":
return {"type": "image", "data": data}
if seg.type in ("record", "voice", "audio"):
return {"type": "audio", "data": data}
if seg.type == "video":
return {"type": "video", "data": data}
return {"type": seg.type, "data": data}
if len(segments) == 1:
seg = segments[0]
if seg.type == "text":
return {"type": "text", "text": seg.data.get("text", "")}
elif seg.type == "image":
return {"type": "image", "url": seg.data.get("file", "")}
elif seg.type == "record":
return {"type": "audio", "url": seg.data.get("file", "")}
elif seg.type == "video":
return {"type": "video", "url": seg.data.get("file", "")}
# 多个段或未知类型 - 合并为文本
text_parts = []
for seg in segments:
if seg.type == "text":
text_parts.append(seg.data.get("text", ""))
elif seg.type == "image":
text_parts.append("[图片]")
elif seg.type == "record":
text_parts.append("[语音]")
elif seg.type == "video":
text_parts.append("[视频]")
else:
text_parts.append(f"[{seg.type}]")
return {"type": "text", "text": "".join(text_parts)}
return _seg_to_content(segments[0])
return {"type": "collection", "data": [_seg_to_content(seg) for seg in segments]}
__all__ = ["EnvelopeConverter"]