"""NapCat 入站消息编解码。""" from __future__ import annotations from typing import Any, Dict, List, Mapping, Optional, Tuple from uuid import uuid4 import hashlib import time from ...qq_emoji_list import QQ_FACE from ...services import NapCatQueryService from ...types import NapCatIncomingSegment, NapCatIncomingSegments, NapCatPayload, NapCatSegment, NapCatSegments from ..notice.helpers import normalize_optional_string from .cards import NapCatInboundCardMixin from .text import NapCatInboundTextMixin class NapCatInboundCodec(NapCatInboundCardMixin, NapCatInboundTextMixin): """NapCat 入站消息编码器。""" def __init__(self, logger: Any, query_service: NapCatQueryService) -> None: """初始化入站消息编码器。 Args: logger: 插件日志对象。 query_service: QQ 查询服务。 """ self._logger = logger self._query_service = query_service async def build_message_dict( self, payload: NapCatPayload, self_id: str, sender_user_id: str, sender: Mapping[str, Any], ) -> Dict[str, Any]: """构造 Host 侧可接受的 ``MessageDict``。 Args: payload: NapCat 原始消息事件。 self_id: 当前机器人账号 ID。 sender_user_id: 发送者用户 ID。 sender: 发送者信息字典。 Returns: Dict[str, Any]: 规范化后的 ``MessageDict``。 """ message_type = str(payload.get("message_type") or "").strip() or "private" group_id = str(payload.get("group_id") or "").strip() group_name = str(payload.get("group_name") or "").strip() or (f"group_{group_id}" if group_id else "") user_nickname = str(sender.get("nickname") or sender.get("card") or sender_user_id).strip() or sender_user_id user_cardname = str(sender.get("card") or "").strip() or None raw_message, is_at = await self.convert_segments(payload, self_id) if not raw_message: raw_message = [self._build_text_segment("[unsupported]")] plain_text = self.build_plain_text(raw_message) timestamp_seconds = payload.get("time") if not isinstance(timestamp_seconds, (int, float)): timestamp_seconds = time.time() additional_config: Dict[str, Any] = {"self_id": self_id, "napcat_message_type": message_type} if group_id: additional_config["platform_io_target_group_id"] = group_id else: additional_config["platform_io_target_user_id"] = sender_user_id message_info: Dict[str, Any] = { "user_info": { "user_id": sender_user_id, "user_nickname": user_nickname, "user_cardname": user_cardname, }, "additional_config": additional_config, } if group_id: message_info["group_info"] = {"group_id": group_id, "group_name": group_name} message_id = str(payload.get("message_id") or f"napcat-{uuid4().hex}").strip() return { "message_id": message_id, "timestamp": str(float(timestamp_seconds)), "platform": "qq", "message_info": message_info, "raw_message": raw_message, "is_mentioned": is_at, "is_at": is_at, "is_emoji": False, "is_picture": False, "is_command": plain_text.startswith("/"), "is_notify": False, "session_id": "", "processed_plain_text": plain_text, "display_message": plain_text, } async def convert_segments(self, payload: NapCatPayload, self_id: str) -> Tuple[NapCatSegments, bool]: """将 OneBot 消息段转换为 Host 消息段结构。 Args: payload: OneBot 原始消息事件。 self_id: 当前机器人账号 ID。 Returns: Tuple[NapCatSegments, bool]: 转换后的消息段列表,以及是否 @ 到当前机器人。 Raises: ValueError: 当载荷缺少结构化 ``message`` 段列表时抛出。 """ message_payload = self._require_message_segments(payload) group_id = str(payload.get("group_id") or "").strip() return await self._convert_incoming_segments(message_payload, self_id, group_id) def _require_message_segments(self, payload: NapCatPayload) -> NapCatIncomingSegments: """从 NapCat 载荷中提取结构化消息段列表。 Args: payload: NapCat / OneBot 原始载荷。 Returns: NapCatIncomingSegments: 规范化后的结构化消息段列表。 Raises: ValueError: 当 ``message`` 字段不是结构化段列表时抛出。 """ message_payload = payload.get("message") if not isinstance(message_payload, list): raise ValueError("NapCat 入站消息缺少结构化 message 段列表") normalized_segments = self._normalize_incoming_segments(message_payload) if not normalized_segments: raise ValueError("NapCat 入站消息未包含可识别的结构化消息段") return normalized_segments def _normalize_incoming_segments(self, message_payload: List[Any]) -> NapCatIncomingSegments: """规范化 NapCat / OneBot 原始消息段列表。 Args: message_payload: 原始 ``message`` 字段值。 Returns: NapCatIncomingSegments: 过滤并标准化后的消息段列表。 """ normalized_segments: NapCatIncomingSegments = [] for segment in message_payload: if not isinstance(segment, Mapping): continue segment_type = str(segment.get("type") or "").strip() segment_data = segment.get("data", {}) if not segment_type or not isinstance(segment_data, Mapping): continue normalized_segments.append( NapCatIncomingSegment( type=segment_type, data=dict(segment_data), ) ) return normalized_segments async def _convert_incoming_segments( self, message_payload: NapCatIncomingSegments, self_id: str, group_id: str, ) -> Tuple[NapCatSegments, bool]: """将结构化 OneBot 消息段转换为 Host 消息段结构。 Args: message_payload: NapCat / OneBot 结构化消息段列表。 self_id: 当前机器人账号 ID。 group_id: 当前消息所在群号;私聊消息为空字符串。 Returns: Tuple[NapCatSegments, bool]: 转换后的消息段列表,以及是否 @ 到当前机器人。 """ converted_segments: NapCatSegments = [] at_target_cache: Dict[str, Tuple[Optional[str], Optional[str]]] = {} is_at = False for segment in message_payload: segment_type = str(segment.get("type") or "").strip() segment_data = segment.get("data", {}) if not isinstance(segment_data, Mapping): segment_data = {} if segment_type == "text": if text_value := str(segment_data.get("text") or ""): converted_segments.append(self._build_text_segment(text_value)) continue if segment_type == "at": if target_user_id := str(segment_data.get("qq") or "").strip(): if target_user_id in at_target_cache: target_user_nickname, target_user_cardname = at_target_cache[target_user_id] else: target_user_nickname, target_user_cardname = await self._resolve_at_target_info( group_id=group_id, target_user_id=target_user_id, ) at_target_cache[target_user_id] = (target_user_nickname, target_user_cardname) converted_segments.append( { "type": "at", "data": { "target_user_id": target_user_id, "target_user_nickname": target_user_nickname, "target_user_cardname": target_user_cardname, }, } ) if self_id and target_user_id == self_id: is_at = True continue if segment_type == "reply": if reply_segment := await self._build_reply_segment(segment_data): converted_segments.append(reply_segment) continue if segment_type == "face": converted_segments.append(self._build_face_text_segment(segment_data)) continue if segment_type == "image": converted_segments.append(await self._build_image_like_segment(segment_data, is_emoji=False)) continue if segment_type == "record": converted_segments.append(await self._build_record_segment(segment_data)) continue if segment_type == "video": converted_segments.append(self._build_video_text_segment(segment_data)) continue if segment_type == "file": converted_segments.append(self._build_file_text_segment(segment_data)) continue if segment_type == "json": converted_segments.extend(await self._build_json_segments(segment_data)) continue if segment_type == "forward": if forward_segment := await self._build_forward_segment(segment_data): converted_segments.append(forward_segment) continue if segment_type in {"xml", "share"}: converted_segments.append(self._build_text_segment(f"[{segment_type}]")) return converted_segments, is_at async def _resolve_at_target_info( self, group_id: str, target_user_id: str, ) -> Tuple[Optional[str], Optional[str]]: """解析 ``at`` 目标的展示信息。 Args: group_id: 当前消息所在群号;私聊消息为空字符串。 target_user_id: 被 ``at`` 的用户号。 Returns: Tuple[Optional[str], Optional[str]]: 依次返回 QQ 昵称和群昵称。 """ if not target_user_id or target_user_id == "all": return None, None target_user_nickname: Optional[str] = None target_user_cardname: Optional[str] = None if group_id: member_info = await self._query_service.get_group_member_info(group_id, target_user_id, no_cache=True) if member_info is not None: target_user_nickname = normalize_optional_string(member_info.get("nickname")) target_user_cardname = normalize_optional_string(member_info.get("card")) if target_user_nickname or target_user_cardname: return target_user_nickname, target_user_cardname stranger_info = await self._query_service.get_stranger_info(target_user_id) if stranger_info is None: return None, None return normalize_optional_string(stranger_info.get("nickname")), target_user_cardname @staticmethod def _build_text_segment(text: str) -> NapCatSegment: """构造一条纯文本 Host 消息段。 Args: text: 文本内容。 Returns: NapCatSegment: Host 侧纯文本消息段。 """ return {"type": "text", "data": text} async def _build_reply_segment(self, segment_data: Mapping[str, Any]) -> Optional[NapCatSegment]: """构造回复消息段。 Args: segment_data: OneBot ``reply`` 段的 ``data`` 字典。 Returns: Optional[NapCatSegment]: 转换后的回复消息段;缺少消息 ID 时返回 ``None``。 """ target_message_id = str(segment_data.get("id") or "").strip() if not target_message_id: return None message_detail = await self._query_service.get_message_detail(target_message_id) reply_payload: Dict[str, Any] = {"target_message_id": target_message_id} if message_detail is not None: sender = message_detail.get("sender", {}) if not isinstance(sender, Mapping): sender = {} reply_payload["target_message_content"] = await self._build_reply_preview_text(message_detail) reply_payload["target_message_sender_id"] = ( str(message_detail.get("user_id") or sender.get("user_id") or "").strip() or None ) reply_payload["target_message_sender_nickname"] = str(sender.get("nickname") or "").strip() or None reply_payload["target_message_sender_cardname"] = str(sender.get("card") or "").strip() or None return {"type": "reply", "data": reply_payload} async def _build_reply_preview_text(self, message_detail: NapCatPayload) -> Optional[str]: """为回复引用构造结构化消息预览文本。 Args: message_detail: ``get_msg`` 返回的消息详情。 Returns: Optional[str]: 基于结构化消息段生成的预览文本;无法生成时返回 ``None``。 """ try: reply_segments, _ = await self.convert_segments(message_detail, "") except ValueError: return None if not reply_segments: return None return self.build_plain_text(reply_segments) async def _build_image_like_segment( self, segment_data: Mapping[str, Any], is_emoji: bool, ) -> NapCatSegment: """构造图片或表情消息段。 Args: segment_data: OneBot ``image`` 段的 ``data`` 字典。 is_emoji: 是否按表情组件处理。 Returns: NapCatSegment: 转换后的图片或表情消息段。 """ subtype = self._normalize_numeric_segment_value(segment_data.get("sub_type")) actual_is_emoji = is_emoji or (subtype is not None and subtype not in {0, 4, 9}) image_url = str(segment_data.get("url") or "").strip() binary_data = await self._query_service.download_binary(image_url) if not binary_data: return self._build_text_segment("[emoji]" if actual_is_emoji else "[image]") return { "type": "emoji" if actual_is_emoji else "image", "data": "", "hash": hashlib.sha256(binary_data).hexdigest(), "binary_data_base64": self._encode_binary(binary_data), } async def _build_record_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: """构造语音消息段。 Args: segment_data: OneBot ``record`` 段的 ``data`` 字典。 Returns: NapCatSegment: 转换后的语音或占位文本消息段。 """ file_name = str(segment_data.get("file") or "").strip() file_id = str(segment_data.get("file_id") or "").strip() or None if not file_name: return self._build_text_segment("[voice]") record_detail = await self._query_service.get_record_detail(file_name=file_name, file_id=file_id) if record_detail is None: return self._build_text_segment("[voice]") record_base64 = str(record_detail.get("base64") or "").strip() if not record_base64: return self._build_text_segment("[voice]") try: binary_data = self._decode_binary(record_base64) except Exception: return self._build_text_segment("[voice]") return { "type": "voice", "data": "", "hash": hashlib.sha256(binary_data).hexdigest(), "binary_data_base64": self._encode_binary(binary_data), } def _build_face_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: """构造 QQ 原生表情文本段。 Args: segment_data: OneBot ``face`` 段的 ``data`` 字典。 Returns: NapCatSegment: 转换后的文本消息段。 """ face_id = str(segment_data.get("id") or "").strip() face_text = QQ_FACE.get(face_id, "[表情]") return self._build_text_segment(face_text) def _build_video_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: """构造视频消息的可读文本段。 Args: segment_data: OneBot ``video`` 段的 ``data`` 字典。 Returns: NapCatSegment: 转换后的文本消息段。 """ file_name = str(segment_data.get("file") or "").strip() file_size = str(segment_data.get("file_size") or "").strip() parts: List[str] = [] if file_name: parts.append(f"文件: {file_name}") if file_size: parts.append(f"大小: {file_size}") if parts: return self._build_text_segment(f"[视频] {','.join(parts)}") return self._build_text_segment("[视频]") def _build_file_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: """构造文件消息的可读文本段。 Args: segment_data: OneBot ``file`` 段的 ``data`` 字典。 Returns: NapCatSegment: 转换后的文本消息段。 """ file_name = str(segment_data.get("file") or segment_data.get("name") or "").strip() file_size = str(segment_data.get("file_size") or "").strip() file_url = str(segment_data.get("url") or "").strip() text_parts: List[str] = [] if file_name: text_parts.append(file_name) if file_size: text_parts.append(f"大小: {file_size}") file_text = "[文件]" if text_parts: file_text = f"[文件] {','.join(text_parts)}" if file_url: file_text = f"{file_text},链接: {file_url}" return self._build_text_segment(file_text) async def _build_forward_segment(self, segment_data: Mapping[str, Any]) -> Optional[NapCatSegment]: """构造合并转发消息段。 Args: segment_data: OneBot ``forward`` 段的 ``data`` 字典。 Returns: Optional[NapCatSegment]: 转换后的合并转发消息段;失败时返回 ``None``。 """ inline_messages = self._extract_forward_messages(segment_data) messages = inline_messages if messages is None: message_id = str(segment_data.get("id") or "").strip() if not message_id: return None forward_detail = await self._query_service.get_forward_message(message_id) if forward_detail is None: return self._build_text_segment("[forward]") messages = self._extract_forward_messages(forward_detail) if not isinstance(messages, list): return self._build_text_segment("[forward]") forward_nodes = await self._build_forward_nodes(messages) if not forward_nodes: return self._build_text_segment("[forward]") return {"type": "forward", "data": forward_nodes} def _extract_forward_messages(self, payload: Mapping[str, Any]) -> Optional[List[Any]]: """从转发载荷中提取节点列表。 Args: payload: 转发段 ``data`` 或 ``get_forward_msg`` 返回的载荷。 Returns: Optional[List[Any]]: 提取到的节点列表;当载荷中不存在节点列表时返回 ``None``。 """ direct_messages = payload.get("messages") if isinstance(direct_messages, list): return direct_messages direct_content = payload.get("content") if isinstance(direct_content, list): return direct_content nested_data = payload.get("data") if isinstance(nested_data, Mapping): nested_messages = nested_data.get("messages") if isinstance(nested_messages, list): return nested_messages nested_content = nested_data.get("content") if isinstance(nested_content, list): return nested_content return None async def _build_forward_nodes(self, messages: List[Any]) -> List[Dict[str, Any]]: """将 NapCat 转发节点列表转换为 Host 转发节点列表。 Args: messages: NapCat 返回的转发节点列表。 Returns: List[Dict[str, Any]]: Host 侧可识别的转发节点列表。 """ forward_nodes: List[Dict[str, Any]] = [] for forward_message in messages: if not isinstance(forward_message, Mapping): continue raw_content = self._extract_forward_node_content(forward_message) content_segments = await self._convert_forward_content(raw_content, "") sender = self._extract_forward_node_sender(forward_message) node_data = forward_message.get("data", {}) if not isinstance(node_data, Mapping): node_data = {} forward_nodes.append( { "user_id": str( sender.get("user_id") or sender.get("uin") or node_data.get("user_id") or node_data.get("uin") or "" ).strip() or None, "user_nickname": str( sender.get("nickname") or sender.get("name") or node_data.get("nickname") or node_data.get("name") or "未知用户" ), "user_cardname": str(sender.get("card") or node_data.get("card") or "").strip() or None, "message_id": str( forward_message.get("message_id") or forward_message.get("id") or node_data.get("id") or uuid4().hex ), "content": content_segments or [self._build_text_segment("[empty]")], } ) return forward_nodes def _extract_forward_node_content(self, forward_message: Mapping[str, Any]) -> Any: """提取单个转发节点中的消息段列表。 Args: forward_message: NapCat 返回的单个转发节点。 Returns: Any: 原始消息段列表;不存在时返回空列表。 """ direct_content = forward_message.get("content") if isinstance(direct_content, list): return direct_content direct_message = forward_message.get("message") if isinstance(direct_message, list): return direct_message node_data = forward_message.get("data", {}) if not isinstance(node_data, Mapping): return [] nested_content = node_data.get("content") if isinstance(nested_content, list): return nested_content nested_message = node_data.get("message") if isinstance(nested_message, list): return nested_message return [] def _extract_forward_node_sender(self, forward_message: Mapping[str, Any]) -> Mapping[str, Any]: """提取单个转发节点的发送者信息。 Args: forward_message: NapCat 返回的单个转发节点。 Returns: Mapping[str, Any]: 归一化后的发送者信息映射。 """ sender = forward_message.get("sender", {}) if isinstance(sender, Mapping): return sender node_data = forward_message.get("data", {}) if not isinstance(node_data, Mapping): return {} normalized_sender: Dict[str, Any] = {} user_id = str(node_data.get("user_id") or node_data.get("uin") or "").strip() nickname = str(node_data.get("nickname") or node_data.get("name") or "").strip() cardname = str(node_data.get("card") or "").strip() if user_id: normalized_sender["user_id"] = user_id normalized_sender["uin"] = user_id if nickname: normalized_sender["nickname"] = nickname normalized_sender["name"] = nickname if cardname: normalized_sender["card"] = cardname return normalized_sender async def _convert_forward_content(self, raw_content: Any, self_id: str) -> NapCatSegments: """转换转发节点内部的消息段列表。 Args: raw_content: 转发节点原始内容。 self_id: 当前机器人账号 ID。 Returns: NapCatSegments: 转换后的消息段列表。 """ if not isinstance(raw_content, list): return [] normalized_segments = self._normalize_incoming_segments(raw_content) if not normalized_segments: return [] segments, _ = await self._convert_incoming_segments(normalized_segments, self_id, "") return segments