501 lines
18 KiB
Python
501 lines
18 KiB
Python
"""NapCat 出站消息段编码器。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Dict, List, Mapping
|
|
|
|
|
|
class NapCatOutboundSegmentEncoder:
|
|
"""将 Host 消息段转换为 NapCat 消息段。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化出站消息段编码器。"""
|
|
self._segment_builders: Dict[str, Callable[[Mapping[str, Any]], List[Dict[str, Any]]]] = {
|
|
"at": self._build_at_segments,
|
|
"dict": self._build_dict_segments,
|
|
"emoji": self._build_emoji_segments,
|
|
"face": self._build_face_segments,
|
|
"file": self._build_file_segments,
|
|
"forward": self._build_forward_segments,
|
|
"image": self._build_image_segments,
|
|
"imageurl": self._build_imageurl_segments,
|
|
"music": self._build_music_segments,
|
|
"reply": self._build_reply_segments,
|
|
"text": self._build_text_segments,
|
|
"video": self._build_video_segments,
|
|
"videourl": self._build_videourl_segments,
|
|
"voice": self._build_voice_segments,
|
|
"voiceurl": self._build_voiceurl_segments,
|
|
}
|
|
|
|
def convert_segments(self, raw_message: Any) -> List[Dict[str, Any]]:
|
|
"""将 Host 消息段转换为 OneBot 消息段。
|
|
|
|
Args:
|
|
raw_message: Host 侧 ``raw_message`` 字段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: OneBot 消息段列表。
|
|
"""
|
|
if not isinstance(raw_message, list):
|
|
return [{"type": "text", "data": {"text": ""}}]
|
|
|
|
outbound_segments: List[Dict[str, Any]] = []
|
|
for item in raw_message:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
|
|
item_type = str(item.get("type") or "").strip()
|
|
segment_builder = self._segment_builders.get(item_type)
|
|
if segment_builder is None:
|
|
fallback_text = f"[unsupported:{item_type or 'unknown'}]"
|
|
outbound_segments.append({"type": "text", "data": {"text": fallback_text}})
|
|
continue
|
|
|
|
built_segments = segment_builder(item)
|
|
if built_segments:
|
|
outbound_segments.extend(built_segments)
|
|
continue
|
|
|
|
fallback_text = self._build_empty_segment_fallback(item_type)
|
|
outbound_segments.append({"type": "text", "data": {"text": fallback_text}})
|
|
|
|
if not outbound_segments:
|
|
outbound_segments.append({"type": "text", "data": {"text": ""}})
|
|
return outbound_segments
|
|
|
|
@staticmethod
|
|
def _build_empty_segment_fallback(item_type: str) -> str:
|
|
"""为缺少有效数据的消息段生成占位文本。
|
|
|
|
Args:
|
|
item_type: 原始消息段类型。
|
|
|
|
Returns:
|
|
str: 用于降级展示的占位文本。
|
|
"""
|
|
normalized_type = item_type or "unknown"
|
|
fallback_map = {
|
|
"emoji": "[emoji]",
|
|
"face": "[face]",
|
|
"file": "[file]",
|
|
"image": "[image]",
|
|
"imageurl": "[image]",
|
|
"music": "[music]",
|
|
"video": "[video]",
|
|
"videourl": "[video]",
|
|
"voice": "[voice]",
|
|
"voiceurl": "[voice]",
|
|
}
|
|
return fallback_map.get(normalized_type, f"[unsupported:{normalized_type}]")
|
|
|
|
def _build_text_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造文本消息段。
|
|
|
|
Args:
|
|
item: Host 侧文本消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 文本消息段列表。
|
|
"""
|
|
text_value = str(item.get("data") or "")
|
|
return [{"type": "text", "data": {"text": text_value}}]
|
|
|
|
def _build_at_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造 @ 消息段。
|
|
|
|
Args:
|
|
item: Host 侧 @ 消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat @ 消息段列表。
|
|
"""
|
|
item_data = item.get("data")
|
|
if not isinstance(item_data, Mapping):
|
|
return []
|
|
target_user_id = str(item_data.get("target_user_id") or "").strip()
|
|
if not target_user_id:
|
|
return []
|
|
return [{"type": "at", "data": {"qq": target_user_id}}]
|
|
|
|
def _build_reply_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造回复消息段。
|
|
|
|
Args:
|
|
item: Host 侧回复消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 回复消息段列表。
|
|
"""
|
|
item_data = item.get("data")
|
|
if isinstance(item_data, Mapping):
|
|
target_message_id = str(item_data.get("target_message_id") or "").strip()
|
|
else:
|
|
target_message_id = str(item_data or "").strip()
|
|
if not target_message_id:
|
|
return []
|
|
return [{"type": "reply", "data": {"id": target_message_id}}]
|
|
|
|
def _build_image_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造图片消息段。
|
|
|
|
Args:
|
|
item: Host 侧图片消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 图片消息段列表。
|
|
"""
|
|
binary_base64 = str(item.get("binary_data_base64") or "").strip()
|
|
if not binary_base64:
|
|
return []
|
|
return [{"type": "image", "data": {"file": f"base64://{binary_base64}", "sub_type": 0}}]
|
|
|
|
def _build_emoji_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造动画表情消息段。
|
|
|
|
Args:
|
|
item: Host 侧表情消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 表情消息段列表。
|
|
"""
|
|
binary_base64 = str(item.get("binary_data_base64") or "").strip()
|
|
if not binary_base64:
|
|
return []
|
|
return [
|
|
{
|
|
"type": "image",
|
|
"data": {
|
|
"file": f"base64://{binary_base64}",
|
|
"sub_type": 1,
|
|
"summary": "[动画表情]",
|
|
},
|
|
}
|
|
]
|
|
|
|
def _build_voice_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造语音消息段。
|
|
|
|
Args:
|
|
item: Host 侧语音消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 语音消息段列表。
|
|
"""
|
|
return [self._build_voice_segment(item)]
|
|
|
|
def _build_voiceurl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造基于 URL 的语音消息段。
|
|
|
|
Args:
|
|
item: Host 侧语音 URL 消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 语音消息段列表。
|
|
"""
|
|
voice_url_segment = self._build_url_media_segment("record", item.get("data"))
|
|
return [voice_url_segment] if voice_url_segment else []
|
|
|
|
def _build_face_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造 QQ 原生表情消息段。
|
|
|
|
Args:
|
|
item: Host 侧表情消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 表情消息段列表。
|
|
"""
|
|
face_segment = self._build_face_segment(item.get("data"))
|
|
return [face_segment] if face_segment else []
|
|
|
|
def _build_imageurl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造基于 URL 的图片消息段。
|
|
|
|
Args:
|
|
item: Host 侧图片 URL 消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 图片消息段列表。
|
|
"""
|
|
image_segment = self._build_url_media_segment("image", item.get("data"))
|
|
return [image_segment] if image_segment else []
|
|
|
|
def _build_videourl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造基于 URL 的视频消息段。
|
|
|
|
Args:
|
|
item: Host 侧视频 URL 消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 视频消息段列表。
|
|
"""
|
|
video_segment = self._build_url_media_segment("video", item.get("data"))
|
|
return [video_segment] if video_segment else []
|
|
|
|
def _build_video_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造视频消息段。
|
|
|
|
Args:
|
|
item: Host 侧视频消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 视频消息段列表。
|
|
"""
|
|
video_segment = self._build_video_segment(item)
|
|
return [video_segment] if video_segment else []
|
|
|
|
def _build_file_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造文件消息段。
|
|
|
|
Args:
|
|
item: Host 侧文件消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 文件消息段列表。
|
|
"""
|
|
file_segment = self._build_file_segment(item.get("data"))
|
|
return [file_segment] if file_segment else []
|
|
|
|
def _build_music_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造音乐卡片消息段。
|
|
|
|
Args:
|
|
item: Host 侧音乐消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 音乐消息段列表。
|
|
"""
|
|
music_segment = self._build_music_segment(item.get("data"))
|
|
return [music_segment] if music_segment else []
|
|
|
|
def _build_forward_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造合并转发消息段。
|
|
|
|
Args:
|
|
item: Host 侧转发消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 转发节点列表。
|
|
"""
|
|
item_data = item.get("data")
|
|
if not isinstance(item_data, list):
|
|
return []
|
|
return self._build_forward_nodes(item_data)
|
|
|
|
def _build_dict_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
|
|
"""构造 ``DictComponent`` 消息段。
|
|
|
|
Args:
|
|
item: Host 侧 ``DictComponent`` 消息段。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: 构造后的 NapCat 消息段列表。
|
|
"""
|
|
item_data = item.get("data")
|
|
if not isinstance(item_data, Mapping):
|
|
return []
|
|
dict_segment = self._build_dict_component_segment(item_data)
|
|
return [dict_segment] if dict_segment else []
|
|
|
|
def _build_voice_segment(self, item: Mapping[str, Any]) -> Dict[str, Any]:
|
|
"""构造语音消息段。
|
|
|
|
Args:
|
|
item: Host 侧语音消息段。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat ``record`` 消息段;缺少有效数据时返回占位文本段。
|
|
"""
|
|
binary_base64 = str(item.get("binary_data_base64") or "").strip()
|
|
if binary_base64:
|
|
return {"type": "record", "data": {"file": f"base64://{binary_base64}"}}
|
|
|
|
item_data = item.get("data")
|
|
if url_media_segment := self._build_url_media_segment("record", item_data):
|
|
return url_media_segment
|
|
return {"type": "text", "data": {"text": "[voice]"}}
|
|
|
|
def _build_face_segment(self, item_data: Any) -> Dict[str, Any]:
|
|
"""构造 QQ 原生表情消息段。
|
|
|
|
Args:
|
|
item_data: Host 侧表情段数据。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat ``face`` 段;缺少有效表情 ID 时返回空字典。
|
|
"""
|
|
face_id = ""
|
|
if isinstance(item_data, Mapping):
|
|
face_id = str(item_data.get("id") or "").strip()
|
|
else:
|
|
face_id = str(item_data or "").strip()
|
|
if not face_id:
|
|
return {}
|
|
return {"type": "face", "data": {"id": face_id}}
|
|
|
|
def _build_video_segment(self, item: Mapping[str, Any]) -> Dict[str, Any]:
|
|
"""构造视频消息段。
|
|
|
|
Args:
|
|
item: Host 侧视频消息段。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat ``video`` 消息段;缺少有效数据时返回空字典。
|
|
"""
|
|
binary_base64 = str(item.get("binary_data_base64") or "").strip()
|
|
if binary_base64:
|
|
return {"type": "video", "data": {"file": f"base64://{binary_base64}"}}
|
|
return self._build_url_media_segment("video", item.get("data"))
|
|
|
|
def _build_file_segment(self, item_data: Any) -> Dict[str, Any]:
|
|
"""构造文件消息段。
|
|
|
|
Args:
|
|
item_data: Host 侧文件段数据。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat ``file`` 段;缺少有效数据时返回空字典。
|
|
"""
|
|
if isinstance(item_data, str):
|
|
normalized_file = item_data.strip()
|
|
if not normalized_file:
|
|
return {}
|
|
return {"type": "file", "data": {"file": self._normalize_file_reference(normalized_file)}}
|
|
|
|
if not isinstance(item_data, Mapping):
|
|
return {}
|
|
|
|
raw_file = str(item_data.get("file") or "").strip()
|
|
raw_path = str(item_data.get("path") or "").strip()
|
|
raw_url = str(item_data.get("url") or "").strip()
|
|
file_ref = raw_file or raw_path or raw_url
|
|
if not file_ref:
|
|
return {}
|
|
|
|
data: Dict[str, Any] = {"file": self._normalize_file_reference(file_ref)}
|
|
for optional_field in ("name", "thumb"):
|
|
optional_value = str(item_data.get(optional_field) or "").strip()
|
|
if optional_value:
|
|
data[optional_field] = optional_value
|
|
return {"type": "file", "data": data}
|
|
|
|
def _build_music_segment(self, item_data: Any) -> Dict[str, Any]:
|
|
"""构造音乐卡片消息段。
|
|
|
|
Args:
|
|
item_data: Host 侧音乐段数据。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat ``music`` 段;缺少有效数据时返回空字典。
|
|
"""
|
|
if isinstance(item_data, str):
|
|
normalized_song_id = item_data.strip()
|
|
if not normalized_song_id:
|
|
return {}
|
|
return {"type": "music", "data": {"type": "163", "id": normalized_song_id}}
|
|
|
|
if not isinstance(item_data, Mapping):
|
|
return {}
|
|
|
|
platform = str(item_data.get("type") or "163").strip() or "163"
|
|
if platform not in {"163", "qq"}:
|
|
platform = "163"
|
|
song_id = str(item_data.get("id") or "").strip()
|
|
if not song_id:
|
|
return {}
|
|
return {"type": "music", "data": {"type": platform, "id": song_id}}
|
|
|
|
def _build_url_media_segment(self, segment_type: str, item_data: Any) -> Dict[str, Any]:
|
|
"""构造基于 URL 或文件引用的媒体消息段。
|
|
|
|
Args:
|
|
segment_type: 目标消息段类型。
|
|
item_data: Host 侧消息段数据。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat 媒体消息段;缺少有效引用时返回空字典。
|
|
"""
|
|
if isinstance(item_data, Mapping):
|
|
file_reference = str(item_data.get("file") or item_data.get("url") or "").strip()
|
|
else:
|
|
file_reference = str(item_data or "").strip()
|
|
if not file_reference:
|
|
return {}
|
|
return {"type": segment_type, "data": {"file": self._normalize_file_reference(file_reference)}}
|
|
|
|
@staticmethod
|
|
def _normalize_file_reference(file_reference: str) -> str:
|
|
"""规范化文件引用字符串。
|
|
|
|
Args:
|
|
file_reference: 原始文件引用。
|
|
|
|
Returns:
|
|
str: 可供 NapCat 使用的文件引用。
|
|
"""
|
|
if file_reference.startswith(("base64://", "file://", "http://", "https://")):
|
|
return file_reference
|
|
return f"file://{file_reference}"
|
|
|
|
def _build_forward_nodes(self, forward_nodes: List[Any]) -> List[Dict[str, Any]]:
|
|
"""构造 NapCat 转发节点列表。
|
|
|
|
Args:
|
|
forward_nodes: 内部转发节点列表。
|
|
|
|
Returns:
|
|
List[Dict[str, Any]]: NapCat 转发节点列表。
|
|
"""
|
|
built_nodes: List[Dict[str, Any]] = []
|
|
for node in forward_nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
raw_content = node.get("content", [])
|
|
node_segments = self.convert_segments(raw_content)
|
|
built_nodes.append(
|
|
{
|
|
"type": "node",
|
|
"data": {
|
|
"name": str(node.get("user_nickname") or node.get("user_cardname") or "QQ用户"),
|
|
"uin": str(node.get("user_id") or ""),
|
|
"content": node_segments,
|
|
},
|
|
}
|
|
)
|
|
return built_nodes
|
|
|
|
def _build_dict_component_segment(self, item_data: Mapping[str, Any]) -> Dict[str, Any]:
|
|
"""尽力将 ``DictComponent`` 转换为 NapCat 消息段。
|
|
|
|
Args:
|
|
item_data: ``DictComponent`` 原始数据。
|
|
|
|
Returns:
|
|
Dict[str, Any]: NapCat 消息段;不支持时返回占位文本段。
|
|
"""
|
|
raw_type = str(item_data.get("type") or "").strip()
|
|
raw_payload = item_data.get("data", item_data)
|
|
if raw_type == "file":
|
|
return self._build_file_segment(raw_payload)
|
|
if raw_type == "music":
|
|
return self._build_music_segment(raw_payload)
|
|
if raw_type == "video":
|
|
if isinstance(raw_payload, Mapping):
|
|
pseudo_item: Dict[str, Any] = {
|
|
"binary_data_base64": raw_payload.get("binary_data_base64"),
|
|
"data": raw_payload,
|
|
}
|
|
return self._build_video_segment(pseudo_item)
|
|
return self._build_video_segment({"data": raw_payload})
|
|
if raw_type == "face":
|
|
return self._build_face_segment(raw_payload)
|
|
if raw_type == "voiceurl":
|
|
return self._build_url_media_segment("record", raw_payload)
|
|
if raw_type == "imageurl":
|
|
return self._build_url_media_segment("image", raw_payload)
|
|
if raw_type == "videourl":
|
|
return self._build_url_media_segment("video", raw_payload)
|
|
if raw_type in {"image", "record", "reply", "at"} and isinstance(raw_payload, Mapping):
|
|
return {"type": raw_type, "data": dict(raw_payload)}
|
|
return {"type": "text", "data": {"text": f"[unsupported:{raw_type or 'dict'}]"}}
|