chore: import private baseline from gitea state

This commit is contained in:
Losita
2026-05-11 19:24:06 +08:00
parent 161fc42c52
commit 1ba863d135
111 changed files with 10873 additions and 7347 deletions

View File

@@ -0,0 +1 @@
"""NapCat 编解码组件导出。"""

View File

@@ -0,0 +1,5 @@
"""NapCat 入站编解码导出。"""
from .message_codec import NapCatInboundCodec
__all__ = ["NapCatInboundCodec"]

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
"""NapCat 通知编解码导出。"""
from .message_codec import NapCatNoticeCodec
__all__ = ["NapCatNoticeCodec"]

View File

@@ -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}

View File

@@ -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

View File

@@ -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)

View File

@@ -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} 状态异常")

View File

@@ -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(".")

View File

@@ -0,0 +1,5 @@
"""NapCat 出站编解码导出。"""
from .message_codec import NapCatOutboundCodec
__all__ = ["NapCatOutboundCodec"]

View File

@@ -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}

View File

@@ -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'}]"}}