chore: import private baseline from gitea state
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""NapCat 编解码组件导出。"""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""NapCat 入站编解码导出。"""
|
||||
|
||||
from .message_codec import NapCatInboundCodec
|
||||
|
||||
__all__ = ["NapCatInboundCodec"]
|
||||
545
plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py
Normal file
545
plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""NapCat 入站 JSON 卡片解析辅助。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Mapping, Optional
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
from ...qq_emoji_list import QQ_FACE
|
||||
from ...types import NapCatSegment, NapCatSegments
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...services import NapCatQueryService
|
||||
|
||||
|
||||
class NapCatInboundCardMixin:
|
||||
"""封装入站 JSON 卡片与预览内容转换逻辑。"""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_query_service: NapCatQueryService
|
||||
|
||||
@staticmethod
|
||||
def _build_text_segment(text: str) -> NapCatSegment: ...
|
||||
|
||||
@staticmethod
|
||||
def _encode_binary(binary_data: bytes) -> str: ...
|
||||
|
||||
async def _build_json_segments(self, segment_data: Mapping[str, Any]) -> NapCatSegments:
|
||||
"""将 JSON 卡片最佳努力转换为消息段列表。
|
||||
|
||||
Args:
|
||||
segment_data: OneBot ``json`` 段的 ``data`` 字典。
|
||||
|
||||
Returns:
|
||||
NapCatSegments: 转换后的消息段列表。
|
||||
"""
|
||||
json_data = str(segment_data.get("data") or "").strip()
|
||||
if not json_data:
|
||||
return [self._build_text_segment("[json]")]
|
||||
|
||||
try:
|
||||
parsed_json = json.loads(json_data)
|
||||
except Exception:
|
||||
return [self._build_text_segment("[json]")]
|
||||
|
||||
if not isinstance(parsed_json, Mapping):
|
||||
return [self._build_text_segment("[json]")]
|
||||
|
||||
app_name = str(parsed_json.get("app") or "").strip()
|
||||
meta = parsed_json.get("meta", {})
|
||||
if not isinstance(meta, Mapping):
|
||||
meta = {}
|
||||
|
||||
if app_name == "com.tencent.mannounce":
|
||||
return [self._build_mannounce_segment(meta)]
|
||||
|
||||
if app_name in {"com.tencent.music.lua", "com.tencent.structmsg"}:
|
||||
music_segments = self._build_music_card_segments(meta)
|
||||
if music_segments:
|
||||
return music_segments
|
||||
|
||||
if app_name == "com.tencent.miniapp_01":
|
||||
return await self._build_preview_text_segments(
|
||||
self._build_miniapp_text(meta),
|
||||
self._extract_preview_url(meta, "detail_1"),
|
||||
)
|
||||
|
||||
if app_name == "com.tencent.giftmall.giftark":
|
||||
gift_text = self._build_gift_text(meta)
|
||||
if gift_text:
|
||||
return [self._build_text_segment(gift_text)]
|
||||
|
||||
if app_name == "com.tencent.contact.lua":
|
||||
return [self._build_text_segment(self._build_contact_text(meta, "推荐联系人"))]
|
||||
|
||||
if app_name == "com.tencent.troopsharecard":
|
||||
return [self._build_text_segment(self._build_contact_text(meta, "推荐群聊"))]
|
||||
|
||||
if app_name == "com.tencent.tuwen.lua":
|
||||
return await self._build_preview_text_segments(
|
||||
self._build_news_text(meta, default_tag="图文分享"),
|
||||
self._extract_preview_url(meta, "news"),
|
||||
)
|
||||
|
||||
if app_name == "com.tencent.feed.lua":
|
||||
return await self._build_preview_text_segments(
|
||||
self._build_feed_text(meta),
|
||||
self._extract_preview_url(meta, "feed", field_name="cover"),
|
||||
)
|
||||
|
||||
if app_name == "com.tencent.template.qqfavorite.share":
|
||||
return await self._build_preview_text_segments(
|
||||
self._build_favorite_text(meta),
|
||||
self._extract_preview_url(meta, "news"),
|
||||
)
|
||||
|
||||
if app_name == "com.tencent.miniapp.lua":
|
||||
return await self._build_preview_text_segments(
|
||||
self._build_simple_title_text(meta, "miniapp", "QQ空间"),
|
||||
self._extract_preview_url(meta, "miniapp"),
|
||||
)
|
||||
|
||||
if app_name == "com.tencent.forum":
|
||||
forum_segments = await self._build_forum_segments(meta)
|
||||
if forum_segments:
|
||||
return forum_segments
|
||||
|
||||
if app_name == "com.tencent.map":
|
||||
location_text = self._build_location_text(meta)
|
||||
if location_text:
|
||||
return [self._build_text_segment(location_text)]
|
||||
|
||||
if app_name == "com.tencent.together":
|
||||
together_text = self._build_together_text(meta)
|
||||
if together_text:
|
||||
return [self._build_text_segment(together_text)]
|
||||
|
||||
prompt = str(parsed_json.get("prompt") or "").strip()
|
||||
if not prompt and isinstance(meta, Mapping):
|
||||
prompt = str(meta.get("prompt") or "").strip()
|
||||
text = prompt or app_name or "json"
|
||||
return [self._build_text_segment(f"[json:{text}]")]
|
||||
|
||||
def _build_mannounce_segment(self, meta: Mapping[str, Any]) -> NapCatSegment:
|
||||
"""构造群公告文本段。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
NapCatSegment: 群公告文本段。
|
||||
"""
|
||||
mannounce = meta.get("mannounce", {})
|
||||
if not isinstance(mannounce, Mapping):
|
||||
mannounce = {}
|
||||
|
||||
title = str(mannounce.get("title") or "").strip()
|
||||
text = str(mannounce.get("text") or "").strip()
|
||||
encode_flag = mannounce.get("encode")
|
||||
if encode_flag == 1:
|
||||
title = self._safe_base64_decode(title)
|
||||
text = self._safe_base64_decode(text)
|
||||
|
||||
if title and text:
|
||||
content = f"[{title}]:{text}"
|
||||
elif title:
|
||||
content = f"[{title}]"
|
||||
elif text:
|
||||
content = text
|
||||
else:
|
||||
content = "[群公告]"
|
||||
return self._build_text_segment(content)
|
||||
|
||||
def _build_music_card_segments(self, meta: Mapping[str, Any]) -> NapCatSegments:
|
||||
"""构造音乐卡片文本段。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
NapCatSegments: 音乐卡片转换后的消息段列表。
|
||||
"""
|
||||
music = meta.get("music", {})
|
||||
if not isinstance(music, Mapping):
|
||||
return []
|
||||
|
||||
title = str(music.get("title") or "").strip()
|
||||
singer = str(music.get("desc") or music.get("singer") or "").strip()
|
||||
tag = str(music.get("tag") or "音乐分享").strip()
|
||||
text_parts: List[str] = [f"[{tag}]"]
|
||||
if title:
|
||||
text_parts.append(title)
|
||||
if singer:
|
||||
text_parts.append(f"- {singer}")
|
||||
content = " ".join(text_parts).strip() or "[音乐分享]"
|
||||
return [self._build_text_segment(content)]
|
||||
|
||||
async def _build_preview_text_segments(
|
||||
self,
|
||||
text: str,
|
||||
preview_url: str,
|
||||
) -> NapCatSegments:
|
||||
"""构造“文本 + 预览图”消息段列表。
|
||||
|
||||
Args:
|
||||
text: 主文本内容。
|
||||
preview_url: 预览图地址。
|
||||
|
||||
Returns:
|
||||
NapCatSegments: 转换后的消息段列表。
|
||||
"""
|
||||
segments: NapCatSegments = [self._build_text_segment(text or "[卡片消息]")]
|
||||
image_segment = await self._build_remote_image_segment(preview_url)
|
||||
if image_segment is not None:
|
||||
segments.append(image_segment)
|
||||
return segments
|
||||
|
||||
async def _build_remote_image_segment(self, image_url: str) -> Optional[NapCatSegment]:
|
||||
"""从远端图片地址构造图片消息段。
|
||||
|
||||
Args:
|
||||
image_url: 图片地址。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatSegment]: 成功时返回图片消息段,否则返回 ``None``。
|
||||
"""
|
||||
normalized_url = str(image_url or "").strip()
|
||||
if not normalized_url:
|
||||
return None
|
||||
|
||||
binary_data = await self._query_service.download_binary(normalized_url)
|
||||
if not binary_data:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "image",
|
||||
"data": "",
|
||||
"hash": hashlib.sha256(binary_data).hexdigest(),
|
||||
"binary_data_base64": self._encode_binary(binary_data),
|
||||
}
|
||||
|
||||
def _build_miniapp_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造小程序分享文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 小程序分享文本。
|
||||
"""
|
||||
detail = meta.get("detail_1", {})
|
||||
if not isinstance(detail, Mapping):
|
||||
return "[小程序]"
|
||||
title = str(detail.get("title") or "").strip()
|
||||
desc = str(detail.get("desc") or "").strip()
|
||||
if title and desc:
|
||||
return f"[小程序] {title}:{desc}"
|
||||
if title:
|
||||
return f"[小程序] {title}"
|
||||
if desc:
|
||||
return f"[小程序] {desc}"
|
||||
return "[小程序]"
|
||||
|
||||
def _build_gift_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造礼物卡片文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 礼物卡片文本。
|
||||
"""
|
||||
giftark = meta.get("giftark", {})
|
||||
if not isinstance(giftark, Mapping):
|
||||
return "[赠送礼物]"
|
||||
gift_name = str(giftark.get("title") or "礼物").strip()
|
||||
desc = str(giftark.get("desc") or "").strip()
|
||||
if desc:
|
||||
return f"[赠送礼物: {gift_name}] {desc}"
|
||||
return f"[赠送礼物: {gift_name}]"
|
||||
|
||||
def _build_contact_text(self, meta: Mapping[str, Any], default_tag: str) -> str:
|
||||
"""构造推荐联系人或群聊文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
default_tag: 默认标签文本。
|
||||
|
||||
Returns:
|
||||
str: 推荐对象文本。
|
||||
"""
|
||||
contact = meta.get("contact", {})
|
||||
if not isinstance(contact, Mapping):
|
||||
return f"[{default_tag}]"
|
||||
name = str(contact.get("nickname") or "未知对象").strip()
|
||||
tag = str(contact.get("tag") or default_tag).strip() or default_tag
|
||||
return f"[{tag}] {name}"
|
||||
|
||||
def _build_news_text(self, meta: Mapping[str, Any], default_tag: str) -> str:
|
||||
"""构造图文分享文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
default_tag: 默认标签文本。
|
||||
|
||||
Returns:
|
||||
str: 图文分享文本。
|
||||
"""
|
||||
news = meta.get("news", {})
|
||||
if not isinstance(news, Mapping):
|
||||
return f"[{default_tag}]"
|
||||
title = str(news.get("title") or "未知标题").strip()
|
||||
desc = str(news.get("desc") or "").replace("[图片]", "").strip()
|
||||
tag = str(news.get("tag") or default_tag).strip() or default_tag
|
||||
if tag and title and tag in title:
|
||||
title = self._trim_card_title(title.replace(tag, "", 1))
|
||||
if desc:
|
||||
return f"[{tag}] {title}:{desc}"
|
||||
return f"[{tag}] {title}".strip()
|
||||
|
||||
def _build_feed_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造群相册分享文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 群相册分享文本。
|
||||
"""
|
||||
feed = meta.get("feed", {})
|
||||
if not isinstance(feed, Mapping):
|
||||
return "[群相册]"
|
||||
title = str(feed.get("title") or "群相册").strip()
|
||||
tag = str(feed.get("tagName") or "群相册").strip() or "群相册"
|
||||
desc = str(feed.get("forwardMessage") or "").strip()
|
||||
if tag and title and tag in title:
|
||||
title = self._trim_card_title(title.replace(tag, "", 1))
|
||||
if desc:
|
||||
return f"[{tag}] {title}:{desc}"
|
||||
return f"[{tag}] {title}".strip()
|
||||
|
||||
def _build_favorite_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造 QQ 收藏分享文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: QQ 收藏分享文本。
|
||||
"""
|
||||
news = meta.get("news", {})
|
||||
if not isinstance(news, Mapping):
|
||||
return "[QQ收藏]"
|
||||
desc = str(news.get("desc") or "").replace("[图片]", "").strip()
|
||||
tag = str(news.get("tag") or "QQ收藏").strip() or "QQ收藏"
|
||||
if desc:
|
||||
return f"[{tag}] {desc}"
|
||||
return f"[{tag}]"
|
||||
|
||||
def _build_simple_title_text(
|
||||
self,
|
||||
meta: Mapping[str, Any],
|
||||
key: str,
|
||||
default_tag: str,
|
||||
) -> str:
|
||||
"""构造简单标题类卡片文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
key: 子对象键名。
|
||||
default_tag: 默认标签文本。
|
||||
|
||||
Returns:
|
||||
str: 简单标题文本。
|
||||
"""
|
||||
nested_payload = meta.get(key, {})
|
||||
if not isinstance(nested_payload, Mapping):
|
||||
return f"[{default_tag}]"
|
||||
title = str(nested_payload.get("title") or "未知标题").strip()
|
||||
tag = str(nested_payload.get("tag") or default_tag).strip() or default_tag
|
||||
return f"[{tag}] {title}".strip()
|
||||
|
||||
async def _build_forum_segments(self, meta: Mapping[str, Any]) -> NapCatSegments:
|
||||
"""构造 QQ 频道帖子消息段。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
NapCatSegments: 频道帖子转换后的消息段列表。
|
||||
"""
|
||||
detail = meta.get("detail", {})
|
||||
if not isinstance(detail, Mapping):
|
||||
return []
|
||||
|
||||
feed = detail.get("feed", {})
|
||||
poster = detail.get("poster", {})
|
||||
channel_info = detail.get("channel_info", {})
|
||||
if not isinstance(feed, Mapping) or not isinstance(poster, Mapping) or not isinstance(channel_info, Mapping):
|
||||
return []
|
||||
|
||||
guild_name = str(channel_info.get("guild_name") or "").strip()
|
||||
nick = str(poster.get("nick") or "QQ用户").strip() or "QQ用户"
|
||||
title = self._extract_forum_title(feed)
|
||||
face_content = self._extract_forum_face_text(feed)
|
||||
|
||||
text_prefix = "[频道帖子]"
|
||||
if guild_name:
|
||||
text_prefix = f"{text_prefix} [{guild_name}]"
|
||||
text_content = f"{text_prefix}{nick}:{title}{face_content}"
|
||||
segments: NapCatSegments = [self._build_text_segment(text_content)]
|
||||
|
||||
images = feed.get("images", [])
|
||||
if not isinstance(images, list):
|
||||
return segments
|
||||
|
||||
for image_item in images:
|
||||
if not isinstance(image_item, Mapping):
|
||||
continue
|
||||
image_segment = await self._build_remote_image_segment(str(image_item.get("pic_url") or "").strip())
|
||||
if image_segment is not None:
|
||||
segments.append(image_segment)
|
||||
return segments
|
||||
|
||||
def _extract_forum_title(self, feed: Mapping[str, Any]) -> str:
|
||||
"""提取 QQ 频道帖子标题。
|
||||
|
||||
Args:
|
||||
feed: 频道帖子 ``feed`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 帖子标题。
|
||||
"""
|
||||
title_payload = feed.get("title", {})
|
||||
if not isinstance(title_payload, Mapping):
|
||||
return "帖子"
|
||||
contents = title_payload.get("contents", [])
|
||||
if not isinstance(contents, list) or not contents:
|
||||
return "帖子"
|
||||
first_content = contents[0]
|
||||
if not isinstance(first_content, Mapping):
|
||||
return "帖子"
|
||||
text_content = first_content.get("text_content", {})
|
||||
if not isinstance(text_content, Mapping):
|
||||
return "帖子"
|
||||
return str(text_content.get("text") or "帖子").strip() or "帖子"
|
||||
|
||||
def _extract_forum_face_text(self, feed: Mapping[str, Any]) -> str:
|
||||
"""提取 QQ 频道帖子中的表情文本。
|
||||
|
||||
Args:
|
||||
feed: 频道帖子 ``feed`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 合并后的表情文本。
|
||||
"""
|
||||
contents_payload = feed.get("contents", {})
|
||||
if not isinstance(contents_payload, Mapping):
|
||||
return ""
|
||||
contents = contents_payload.get("contents", [])
|
||||
if not isinstance(contents, list):
|
||||
return ""
|
||||
|
||||
face_text_parts: List[str] = []
|
||||
for item in contents:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
emoji_content = item.get("emoji_content", {})
|
||||
if not isinstance(emoji_content, Mapping):
|
||||
continue
|
||||
emoji_id = str(emoji_content.get("id") or "").strip()
|
||||
if emoji_id in QQ_FACE:
|
||||
face_text_parts.append(QQ_FACE[emoji_id])
|
||||
return "".join(face_text_parts)
|
||||
|
||||
def _build_location_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造位置分享文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 位置分享文本。
|
||||
"""
|
||||
location = meta.get("Location.Search", {})
|
||||
if not isinstance(location, Mapping):
|
||||
return "[位置]"
|
||||
name = str(location.get("name") or "未知地点").strip()
|
||||
address = str(location.get("address") or "").strip()
|
||||
if address:
|
||||
return f"[位置] {address} · {name}"
|
||||
return f"[位置] {name}"
|
||||
|
||||
def _build_together_text(self, meta: Mapping[str, Any]) -> str:
|
||||
"""构造“一起听歌”文本。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
|
||||
Returns:
|
||||
str: 一起听歌文本。
|
||||
"""
|
||||
invite = meta.get("invite", {})
|
||||
if not isinstance(invite, Mapping):
|
||||
return "[一起听歌]"
|
||||
title = str(invite.get("title") or "一起听歌").strip() or "一起听歌"
|
||||
summary = str(invite.get("summary") or "").strip()
|
||||
if summary:
|
||||
return f"[{title}] {summary}"
|
||||
return f"[{title}]"
|
||||
|
||||
def _extract_preview_url(
|
||||
self,
|
||||
meta: Mapping[str, Any],
|
||||
key: str,
|
||||
field_name: str = "preview",
|
||||
) -> str:
|
||||
"""从卡片元数据中提取预览图地址。
|
||||
|
||||
Args:
|
||||
meta: JSON 卡片 ``meta`` 数据。
|
||||
key: 子对象键名。
|
||||
field_name: 预览图字段名。
|
||||
|
||||
Returns:
|
||||
str: 预览图地址;不存在时返回空字符串。
|
||||
"""
|
||||
nested_payload = meta.get(key, {})
|
||||
if not isinstance(nested_payload, Mapping):
|
||||
return ""
|
||||
return str(nested_payload.get(field_name) or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _trim_card_title(title: str) -> str:
|
||||
"""清理卡片标题两侧的常见分隔符。
|
||||
|
||||
Args:
|
||||
title: 原始标题文本。
|
||||
|
||||
Returns:
|
||||
str: 清理后的标题文本。
|
||||
"""
|
||||
return re.sub(r"^[::\s\-—]+|[::\s\-—]+$", "", str(title or "").strip())
|
||||
|
||||
@staticmethod
|
||||
def _safe_base64_decode(encoded_text: str) -> str:
|
||||
"""安全地解码 Base64 文本。
|
||||
|
||||
Args:
|
||||
encoded_text: 待解码的 Base64 文本。
|
||||
|
||||
Returns:
|
||||
str: 解码结果;失败时返回原始文本。
|
||||
"""
|
||||
normalized_text = str(encoded_text or "").strip()
|
||||
if not normalized_text:
|
||||
return ""
|
||||
try:
|
||||
import base64
|
||||
|
||||
return base64.b64decode(normalized_text).decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return normalized_text
|
||||
@@ -0,0 +1,661 @@
|
||||
"""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
|
||||
@@ -0,0 +1,90 @@
|
||||
"""NapCat 入站纯文本与二进制辅助。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
import base64
|
||||
|
||||
from ...types import NapCatSegments
|
||||
|
||||
|
||||
class NapCatInboundTextMixin:
|
||||
"""封装入站纯文本与二进制辅助逻辑。"""
|
||||
|
||||
def build_plain_text(self, raw_message: NapCatSegments) -> str:
|
||||
"""从标准消息段中提取可展示的纯文本。
|
||||
|
||||
Args:
|
||||
raw_message: 标准化后的消息段列表。
|
||||
|
||||
Returns:
|
||||
str: 用于 Host 展示和命令判断的纯文本内容。
|
||||
"""
|
||||
plain_text_parts: list[str] = []
|
||||
for item in raw_message:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
item_type = str(item.get("type") or "").strip()
|
||||
item_data = item.get("data")
|
||||
if item_type == "text":
|
||||
plain_text_parts.append(str(item_data or ""))
|
||||
elif item_type == "at" and isinstance(item_data, Mapping):
|
||||
at_target_name = str(
|
||||
item_data.get("target_user_cardname")
|
||||
or item_data.get("target_user_nickname")
|
||||
or item_data.get("target_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
if at_target_name:
|
||||
plain_text_parts.append(f"@{at_target_name}")
|
||||
elif item_type == "reply":
|
||||
plain_text_parts.append("[reply]")
|
||||
elif item_type == "forward":
|
||||
plain_text_parts.append("[forward]")
|
||||
elif item_type in {"image", "emoji", "voice"}:
|
||||
plain_text_parts.append(f"[{item_type}]")
|
||||
|
||||
plain_text = "".join(part for part in plain_text_parts if part).strip()
|
||||
return plain_text or "[unsupported]"
|
||||
|
||||
@staticmethod
|
||||
def _encode_binary(binary_data: bytes) -> str:
|
||||
"""将二进制内容编码为 Base64 字符串。
|
||||
|
||||
Args:
|
||||
binary_data: 待编码的二进制内容。
|
||||
|
||||
Returns:
|
||||
str: Base64 编码字符串。
|
||||
"""
|
||||
return base64.b64encode(binary_data).decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _decode_binary(binary_base64: str) -> bytes:
|
||||
"""将 Base64 字符串解码为二进制内容。
|
||||
|
||||
Args:
|
||||
binary_base64: Base64 字符串。
|
||||
|
||||
Returns:
|
||||
bytes: 解码后的二进制内容。
|
||||
"""
|
||||
return base64.b64decode(binary_base64)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_numeric_segment_value(value: Any) -> Any:
|
||||
"""将可安全识别的数字字符串转为整数。
|
||||
|
||||
Args:
|
||||
value: 原始字段值。
|
||||
|
||||
Returns:
|
||||
Any: 规范化后的字段值。
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
stripped_value = value.strip()
|
||||
if stripped_value.isdigit():
|
||||
return int(stripped_value)
|
||||
return stripped_value
|
||||
return value
|
||||
@@ -0,0 +1,5 @@
|
||||
"""NapCat 通知编解码导出。"""
|
||||
|
||||
from .message_codec import NapCatNoticeCodec
|
||||
|
||||
__all__ = ["NapCatNoticeCodec"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""NapCat 通知事件资料补全器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...services import NapCatQueryService
|
||||
from .helpers import normalize_optional_string
|
||||
|
||||
|
||||
class NapCatNoticeEntityResolver:
|
||||
"""为通知事件补全用户和群资料。"""
|
||||
|
||||
def __init__(self, query_service: NapCatQueryService) -> None:
|
||||
"""初始化实体补全器。
|
||||
|
||||
Args:
|
||||
query_service: NapCat 查询服务。
|
||||
"""
|
||||
self._query_service = query_service
|
||||
|
||||
async def build_user_info(self, group_id: str, user_id: str) -> Dict[str, Optional[str]]:
|
||||
"""构造通知消息的用户信息。
|
||||
|
||||
Args:
|
||||
group_id: 群号;私聊或系统通知时为空字符串。
|
||||
user_id: 事件关联用户号。
|
||||
|
||||
Returns:
|
||||
Dict[str, Optional[str]]: 规范化后的用户信息字典。
|
||||
"""
|
||||
if not user_id:
|
||||
return {
|
||||
"user_id": "notice",
|
||||
"user_nickname": "系统通知",
|
||||
"user_cardname": None,
|
||||
}
|
||||
|
||||
member_info: Optional[Dict[str, Any]]
|
||||
if group_id:
|
||||
member_info = await self._query_service.get_group_member_info(group_id, user_id)
|
||||
else:
|
||||
member_info = await self._query_service.get_stranger_info(user_id)
|
||||
|
||||
if member_info is None:
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"user_nickname": user_id,
|
||||
"user_cardname": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"user_nickname": str(member_info.get("nickname") or user_id),
|
||||
"user_cardname": normalize_optional_string(member_info.get("card")),
|
||||
}
|
||||
|
||||
async def build_group_info(self, group_id: str) -> Optional[Dict[str, str]]:
|
||||
"""构造通知消息的群信息。
|
||||
|
||||
Args:
|
||||
group_id: 群号。
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, str]]: 群信息字典;若不是群通知则返回 ``None``。
|
||||
"""
|
||||
if not group_id:
|
||||
return None
|
||||
|
||||
group_info = await self._query_service.get_group_info(group_id)
|
||||
group_name = str(group_info.get("group_name") or f"group_{group_id}") if group_info else f"group_{group_id}"
|
||||
return {"group_id": group_id, "group_name": group_name}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""NapCat 通知编解码公共辅助函数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha1
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def build_payload_digest(payload: Mapping[str, Any]) -> str:
|
||||
"""对通知载荷生成稳定哈希。
|
||||
|
||||
Args:
|
||||
payload: 原始通知载荷。
|
||||
|
||||
Returns:
|
||||
str: 基于规范化 JSON 文本生成的 SHA-1 十六进制摘要。
|
||||
"""
|
||||
normalized_payload = normalize_payload_value(payload)
|
||||
serialized_payload = json.dumps(
|
||||
normalized_payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
return sha1(serialized_payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_optional_string(value: Any) -> Optional[str]:
|
||||
"""将任意值规范化为可选字符串。
|
||||
|
||||
Args:
|
||||
value: 待规范化的值。
|
||||
|
||||
Returns:
|
||||
Optional[str]: 规范化后的字符串;若值为空则返回 ``None``。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
normalized_value = str(value).strip()
|
||||
return normalized_value if normalized_value else None
|
||||
|
||||
|
||||
def normalize_payload_value(value: Any) -> Any:
|
||||
"""将通知载荷递归规范化为稳定 JSON 结构。
|
||||
|
||||
Args:
|
||||
value: 待规范化的任意值。
|
||||
|
||||
Returns:
|
||||
Any: 仅包含 JSON 基础类型的稳定结构。
|
||||
"""
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): normalize_payload_value(child_value)
|
||||
for key, child_value in sorted(value.items(), key=lambda item: str(item[0]))
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [normalize_payload_value(item) for item in value]
|
||||
if isinstance(value, set):
|
||||
normalized_items = [normalize_payload_value(item) for item in value]
|
||||
return sorted(normalized_items, key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True))
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def resolve_actor_user_id(payload: Mapping[str, Any]) -> str:
|
||||
"""解析通知事件中的操作者用户号。
|
||||
|
||||
Args:
|
||||
payload: 原始通知事件。
|
||||
|
||||
Returns:
|
||||
str: 规范化后的操作者用户号;无法确定时返回空字符串。
|
||||
"""
|
||||
if bool(payload.get("is_natural_lift", False)):
|
||||
return ""
|
||||
actor_user_id = str(payload.get("operator_id") or payload.get("user_id") or "").strip()
|
||||
if actor_user_id == "0":
|
||||
return ""
|
||||
return actor_user_id
|
||||
@@ -0,0 +1,120 @@
|
||||
"""NapCat 通知事件编解码器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import time
|
||||
|
||||
from ...services import NapCatQueryService
|
||||
from ...types import NapCatPayload, NapCatPayloadDict
|
||||
from .enricher import NapCatNoticeEntityResolver
|
||||
from .helpers import build_payload_digest, resolve_actor_user_id
|
||||
from .meta_event_logger import NapCatMetaEventObserver
|
||||
from .renderer import NapCatNoticeTextRenderer
|
||||
|
||||
|
||||
class NapCatNoticeCodec:
|
||||
"""NapCat QQ 通知事件编码器。"""
|
||||
|
||||
def __init__(self, logger: Any, query_service: NapCatQueryService) -> None:
|
||||
"""初始化通知事件编码器。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
query_service: QQ 查询服务。
|
||||
"""
|
||||
self._entity_resolver = NapCatNoticeEntityResolver(query_service)
|
||||
self._meta_event_observer = NapCatMetaEventObserver(logger)
|
||||
self._renderer = NapCatNoticeTextRenderer()
|
||||
|
||||
async def build_notice_message_dict(self, payload: NapCatPayload) -> Optional[NapCatPayloadDict]:
|
||||
"""将 NapCat ``notice`` 事件转换为 Host 可接受的消息字典。
|
||||
|
||||
Args:
|
||||
payload: NapCat 推送的原始通知事件。
|
||||
|
||||
Returns:
|
||||
Optional[NapCatPayloadDict]: 成功时返回标准 ``MessageDict``;无法识别时返回 ``None``。
|
||||
"""
|
||||
notice_type = str(payload.get("notice_type") or "").strip()
|
||||
if not notice_type:
|
||||
return None
|
||||
|
||||
group_id = str(payload.get("group_id") or "").strip()
|
||||
user_id = resolve_actor_user_id(payload)
|
||||
self_id = str(payload.get("self_id") or "").strip()
|
||||
|
||||
user_info = await self._entity_resolver.build_user_info(group_id=group_id, user_id=user_id)
|
||||
group_info = await self._entity_resolver.build_group_info(group_id)
|
||||
actor_name = user_info.get("user_nickname") or user_id or "系统"
|
||||
notice_text = self._renderer.build_notice_text(payload, actor_name)
|
||||
if not notice_text:
|
||||
return None
|
||||
|
||||
additional_config: Dict[str, Any] = {
|
||||
"self_id": self_id,
|
||||
"napcat_notice_type": notice_type,
|
||||
"napcat_notice_sub_type": str(payload.get("sub_type") or "").strip(),
|
||||
"napcat_notice_payload": dict(payload),
|
||||
}
|
||||
if group_id:
|
||||
additional_config["platform_io_target_group_id"] = group_id
|
||||
elif user_id:
|
||||
additional_config["platform_io_target_user_id"] = user_id
|
||||
|
||||
message_info: Dict[str, Any] = {"user_info": user_info, "additional_config": additional_config}
|
||||
if group_info is not None:
|
||||
message_info["group_info"] = group_info
|
||||
|
||||
timestamp_seconds = payload.get("time")
|
||||
if not isinstance(timestamp_seconds, (int, float)):
|
||||
timestamp_seconds = time.time()
|
||||
|
||||
return {
|
||||
"message_id": f"napcat-notice-{uuid4().hex}",
|
||||
"timestamp": str(float(timestamp_seconds)),
|
||||
"platform": "qq",
|
||||
"message_info": message_info,
|
||||
"raw_message": [{"type": "text", "data": notice_text}],
|
||||
"is_mentioned": False,
|
||||
"is_at": False,
|
||||
"is_emoji": False,
|
||||
"is_picture": False,
|
||||
"is_command": False,
|
||||
"is_notify": True,
|
||||
"session_id": "",
|
||||
"processed_plain_text": notice_text,
|
||||
"display_message": notice_text,
|
||||
}
|
||||
|
||||
def build_notice_dedupe_key(self, payload: NapCatPayload) -> Optional[str]:
|
||||
"""为 NapCat ``notice`` 事件构造稳定的技术性去重键。
|
||||
|
||||
Args:
|
||||
payload: NapCat 推送的原始通知事件。
|
||||
|
||||
Returns:
|
||||
Optional[str]: 若可以构造稳定去重键则返回该键,否则返回 ``None``。
|
||||
"""
|
||||
external_message_id = str(payload.get("message_id") or "").strip()
|
||||
if external_message_id:
|
||||
return external_message_id
|
||||
|
||||
notice_type = str(payload.get("notice_type") or "").strip()
|
||||
if not notice_type:
|
||||
return None
|
||||
|
||||
sub_type = str(payload.get("sub_type") or "").strip()
|
||||
payload_digest = build_payload_digest(payload)
|
||||
suffix = f":{sub_type}" if sub_type else ""
|
||||
return f"notice:{notice_type}{suffix}:{payload_digest}"
|
||||
|
||||
async def handle_meta_event(self, payload: NapCatPayload) -> None:
|
||||
"""处理 ``meta_event`` 事件的日志与状态观测。
|
||||
|
||||
Args:
|
||||
payload: NapCat 推送的原始元事件。
|
||||
"""
|
||||
await self._meta_event_observer.handle_meta_event(payload)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""NapCat 元事件日志处理器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class NapCatMetaEventObserver:
|
||||
"""处理 NapCat 元事件的日志输出。"""
|
||||
|
||||
def __init__(self, logger: Any) -> None:
|
||||
"""初始化元事件观察器。
|
||||
|
||||
Args:
|
||||
logger: 插件日志对象。
|
||||
"""
|
||||
self._logger = logger
|
||||
|
||||
async def handle_meta_event(self, payload: Mapping[str, Any]) -> None:
|
||||
"""处理 ``meta_event`` 事件的日志与状态观测。
|
||||
|
||||
Args:
|
||||
payload: NapCat 推送的原始元事件。
|
||||
"""
|
||||
meta_event_type = str(payload.get("meta_event_type") or "").strip()
|
||||
self_id = str(payload.get("self_id") or "").strip() or "unknown"
|
||||
|
||||
if meta_event_type == "lifecycle":
|
||||
sub_type = str(payload.get("sub_type") or "").strip()
|
||||
if sub_type == "connect":
|
||||
self._logger.info(f"NapCat 元事件:Bot {self_id} 已建立连接")
|
||||
else:
|
||||
self._logger.debug(f"NapCat 生命周期事件: self_id={self_id} sub_type={sub_type}")
|
||||
return
|
||||
|
||||
if meta_event_type == "heartbeat":
|
||||
status = payload.get("status", {})
|
||||
if not isinstance(status, Mapping):
|
||||
status = {}
|
||||
is_online = bool(status.get("online", False))
|
||||
is_good = bool(status.get("good", False))
|
||||
interval_ms = payload.get("interval")
|
||||
self._logger.debug(
|
||||
f"NapCat 心跳事件: self_id={self_id} online={is_online} good={is_good} interval={interval_ms}"
|
||||
)
|
||||
if not is_online:
|
||||
self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 已离线")
|
||||
elif not is_good:
|
||||
self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 状态异常")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""NapCat 通知文本渲染器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class NapCatNoticeTextRenderer:
|
||||
"""根据通知载荷生成可读文本。"""
|
||||
|
||||
def build_notice_text(self, payload: Mapping[str, Any], actor_name: str) -> str:
|
||||
"""根据 NapCat 通知事件生成可读文本。
|
||||
|
||||
Args:
|
||||
payload: 原始通知事件。
|
||||
actor_name: 事件操作者显示名。
|
||||
|
||||
Returns:
|
||||
str: 生成的可读通知文本。
|
||||
"""
|
||||
notice_type = str(payload.get("notice_type") or "").strip()
|
||||
sub_type = str(payload.get("sub_type") or "").strip()
|
||||
target_id = str(payload.get("target_id") or "").strip()
|
||||
target_user_id = str(payload.get("user_id") or "").strip()
|
||||
is_natural_lift = bool(payload.get("is_natural_lift", False))
|
||||
|
||||
if notice_type in {"group_recall", "friend_recall"}:
|
||||
return f"{actor_name} 撤回了一条消息"
|
||||
if notice_type == "notify" and sub_type == "poke":
|
||||
target_text = f" -> {target_id}" if target_id else ""
|
||||
return f"{actor_name} 发起了戳一戳{target_text}"
|
||||
if notice_type == "notify" and sub_type == "group_name":
|
||||
return f"{actor_name} 修改了群名称"
|
||||
if notice_type == "group_ban" and sub_type == "ban":
|
||||
duration = payload.get("duration")
|
||||
if target_user_id in {"", "0"}:
|
||||
return f"{actor_name} 开启了全体禁言"
|
||||
return f"{actor_name} 禁言了用户 {target_user_id},时长 {duration} 秒"
|
||||
if notice_type == "group_ban" and sub_type == "whole_lift_ban":
|
||||
if is_natural_lift:
|
||||
return "群全体禁言已自然解除"
|
||||
return f"{actor_name} 解除了全体禁言"
|
||||
if notice_type == "group_ban" and sub_type == "lift_ban":
|
||||
if is_natural_lift:
|
||||
return f"用户 {target_user_id} 的禁言已自然解除"
|
||||
return f"{actor_name} 解除了用户 {target_user_id} 的禁言"
|
||||
if notice_type == "group_upload":
|
||||
file_info = payload.get("file", {})
|
||||
file_name = ""
|
||||
if isinstance(file_info, Mapping):
|
||||
file_name = str(file_info.get("name") or "").strip()
|
||||
return f"{actor_name} 上传了文件{f':{file_name}' if file_name else ''}"
|
||||
if notice_type == "group_increase":
|
||||
return f"{actor_name} 加入了群聊"
|
||||
if notice_type == "group_decrease":
|
||||
return f"{actor_name} 离开了群聊"
|
||||
if notice_type == "group_admin":
|
||||
return f"{actor_name} 的群管理员状态发生变化"
|
||||
if notice_type == "essence":
|
||||
return f"{actor_name} 触发了精华消息事件"
|
||||
if notice_type == "group_msg_emoji_like":
|
||||
return f"{actor_name} 给一条消息添加了表情回应"
|
||||
return f"[notice] {notice_type}.{sub_type}".strip(".")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""NapCat 出站编解码导出。"""
|
||||
|
||||
from .message_codec import NapCatOutboundCodec
|
||||
|
||||
__all__ = ["NapCatOutboundCodec"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""NapCat 出站消息编解码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Mapping, Tuple
|
||||
|
||||
from .segment_encoder import NapCatOutboundSegmentEncoder
|
||||
|
||||
|
||||
class NapCatOutboundCodec:
|
||||
"""NapCat 出站消息编码器。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化出站消息编码器。"""
|
||||
self._segment_encoder = NapCatOutboundSegmentEncoder()
|
||||
|
||||
def build_outbound_action(
|
||||
self,
|
||||
message: Mapping[str, Any],
|
||||
route: Mapping[str, Any],
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""为 Host 出站消息构造 OneBot 动作。
|
||||
|
||||
Args:
|
||||
message: Host 侧标准 ``MessageDict``。
|
||||
route: Platform IO 路由信息。
|
||||
|
||||
Returns:
|
||||
Tuple[str, Dict[str, Any]]: 动作名称与参数字典。
|
||||
|
||||
Raises:
|
||||
ValueError: 当私聊出站缺少目标用户 ID 时抛出。
|
||||
"""
|
||||
message_info = message.get("message_info", {})
|
||||
if not isinstance(message_info, Mapping):
|
||||
message_info = {}
|
||||
|
||||
group_info = message_info.get("group_info", {})
|
||||
if not isinstance(group_info, Mapping):
|
||||
group_info = {}
|
||||
|
||||
additional_config = message_info.get("additional_config", {})
|
||||
if not isinstance(additional_config, Mapping):
|
||||
additional_config = {}
|
||||
|
||||
raw_message = message.get("raw_message", [])
|
||||
segments = self._segment_encoder.convert_segments(raw_message)
|
||||
|
||||
if target_group_id := str(
|
||||
group_info.get("group_id") or additional_config.get("platform_io_target_group_id") or ""
|
||||
).strip():
|
||||
return "send_group_msg", {"group_id": target_group_id, "message": segments}
|
||||
|
||||
target_user_id = str(
|
||||
additional_config.get("platform_io_target_user_id")
|
||||
or additional_config.get("target_user_id")
|
||||
or route.get("target_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not target_user_id:
|
||||
raise ValueError("Outbound private message is missing target_user_id")
|
||||
|
||||
return "send_private_msg", {"message": segments, "user_id": target_user_id}
|
||||
@@ -0,0 +1,500 @@
|
||||
"""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'}]"}}
|
||||
Reference in New Issue
Block a user