Issue #88: 群聊里的引用回复(appmsg type=57)/ 卡片 / 视频在 export_chat 和 export_all_chats 渲染成 type=link_or_file 且 content 为空。 根因:`_extract_content` 把数据库里带 `wxid_xxx:\n` 群前缀的原始 content 直接喂给 `_format_app_message_text`,XML 解析器在前缀文本上 ParseError, 返回 None。 修复: - 用 `chat_username.endswith('@chatroom')` 判定群聊 - 在 dispatch 前调 `mcp_server._parse_message_content(..., is_group=True)` 剥前缀;逻辑也对群里的 base=1 text 生效(之前同样带前缀) - 把 `is_group=True` 透传给 `_format_app_message_text`,让引用回复走 group 分支的发送者标签解析 - 用 `mcp_server.get_contact_names()` 代替之前硬编码的 `{}`,让 wxid 能 正确解出昵称 测试:新增 5 个测试覆盖群引用回复带前缀 / 1-on-1 不受影响 / 群 text 前缀剥离 / 1-on-1 text 不变 / names dict 正确解析。126/126 通过。 Belugary 在 #100 修了 `_format_app_message_text` 内部的 type=57 schema 渲染(对 get_chat_history 生效),本 PR 是补 export 这条路径上的群前缀 bug。两者互补。 Co-authored-by: ylytdeng <ylytdeng@users.noreply.github.com>
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""聊天导出共享工具函数。
|
||
|
||
本模块包含 export_chat.py 和 export_all_chats.py 共用的消息格式化函数。
|
||
统一维护,避免两处代码漂移。
|
||
"""
|
||
|
||
import base64
|
||
|
||
import mcp_server
|
||
|
||
|
||
MSG_TYPE_MAP = {
|
||
1: "text",
|
||
3: "image",
|
||
34: "voice",
|
||
42: "contact_card",
|
||
43: "video",
|
||
47: "sticker",
|
||
48: "location",
|
||
49: "link_or_file",
|
||
50: "call",
|
||
10000: "system",
|
||
10002: "recall",
|
||
}
|
||
|
||
|
||
def _msg_type_str(local_type):
|
||
base, _ = mcp_server._split_msg_type(local_type)
|
||
return MSG_TYPE_MAP.get(base, f"type_{local_type}")
|
||
|
||
|
||
def _resolve_sender(row, ctx, names, id_to_username):
|
||
"""Resolve the sender of a message.
|
||
|
||
Returns "me" for the logged-in user, or the sender's display name otherwise
|
||
(the contact's name in 1-on-1 chats, the member's name in groups). Empty
|
||
string for unattributable messages (e.g. system notifications).
|
||
"""
|
||
local_id, local_type, create_time, real_sender_id, content, ct = row
|
||
decoded = mcp_server._decompress_content(content, ct)
|
||
sender_from_content, _ = mcp_server._format_message_text(
|
||
local_id, local_type, decoded, ctx["is_group"], ctx["username"], ctx["display_name"], names
|
||
)
|
||
label = mcp_server._resolve_sender_label(
|
||
real_sender_id,
|
||
sender_from_content,
|
||
ctx["is_group"],
|
||
ctx["username"],
|
||
ctx["display_name"],
|
||
names,
|
||
id_to_username,
|
||
)
|
||
return label or ""
|
||
|
||
|
||
def _decode_sticker_desc(b64_desc):
|
||
"""WeChat encodes sticker labels as base64 protobuf: repeated (lang, text) pairs.
|
||
Returns the 'default' language label (usually Chinese), or None.
|
||
|
||
Limitation: treats the length byte as a single octet rather than a real protobuf
|
||
varint — labels >127 bytes would be misread. In practice sticker descriptions are
|
||
short (<30 chars), so this is adequate. Also sensitive to the bytes b"default"
|
||
appearing inside a preceding value; no such cases observed.
|
||
"""
|
||
try:
|
||
raw = base64.b64decode(b64_desc)
|
||
except Exception:
|
||
return None
|
||
# Find the 'default' marker; text follows as: \x12 <varint len> <utf-8>
|
||
i = raw.find(b"default")
|
||
if i < 0 or i + 7 >= len(raw) or raw[i + 7] != 0x12:
|
||
return None
|
||
try:
|
||
text_len = raw[i + 8]
|
||
text_bytes = raw[i + 9 : i + 9 + text_len]
|
||
return text_bytes.decode("utf-8") or None
|
||
except (IndexError, UnicodeDecodeError):
|
||
return None
|
||
|
||
|
||
def _format_sticker_message(content):
|
||
root = mcp_server._parse_xml_root(content) if content else None
|
||
if root is None:
|
||
return "[表情]"
|
||
emoji = root.find(".//emoji")
|
||
if emoji is None:
|
||
return "[表情]"
|
||
desc = emoji.get("desc") or ""
|
||
label = _decode_sticker_desc(desc) if desc else None
|
||
return f"[表情] {label}" if label else "[表情]"
|
||
|
||
|
||
def _format_system_message(content):
|
||
if not content:
|
||
return "[系统消息]"
|
||
if "<sysmsg" not in content:
|
||
return content
|
||
root = mcp_server._parse_xml_root(content)
|
||
if root is None:
|
||
return content
|
||
inner = root.findtext(".//content")
|
||
return inner.strip() if inner else content
|
||
|
||
|
||
def _format_video_message(content):
|
||
root = mcp_server._parse_xml_root(content) if content else None
|
||
if root is None:
|
||
return "[视频]"
|
||
video = root.find(".//videomsg")
|
||
if video is None:
|
||
return "[视频]"
|
||
playlength = video.get("playlength")
|
||
return f"[视频] {playlength}秒" if playlength else "[视频]"
|
||
|
||
|
||
def _extract_transfer_extras(content):
|
||
"""Detect appmsg type=2000 and return structured transfer fields, else None.
|
||
|
||
Reuses mcp_server._extract_transfer_info so the schema/version-quirks logic
|
||
lives in one place. Empty values are dropped to keep the export compact.
|
||
Numeric timestamps are returned as ints (consistent with the top-level
|
||
`timestamp` field), not iso strings — downstream consumers can format.
|
||
"""
|
||
if not content or '<appmsg' not in content:
|
||
return None
|
||
root = mcp_server._parse_app_message_outer(content)
|
||
if root is None:
|
||
return None
|
||
appmsg = root.find('.//appmsg')
|
||
if appmsg is None:
|
||
return None
|
||
app_type = mcp_server._parse_int(
|
||
mcp_server._collapse_text(appmsg.findtext('type') or ''), 0
|
||
)
|
||
if app_type != 2000:
|
||
return None
|
||
|
||
info = mcp_server._extract_transfer_info(appmsg)
|
||
if not info:
|
||
return None
|
||
|
||
out = {}
|
||
if info['paysubtype_label']:
|
||
out['direction'] = info['paysubtype_label']
|
||
for k in ('paysubtype', 'fee_desc', 'pay_memo',
|
||
'payer_username', 'receiver_username',
|
||
'transfer_id', 'transcation_id', 'pay_msg_id'):
|
||
v = info.get(k)
|
||
if v:
|
||
out[k] = v
|
||
for k in ('begin_transfer_time', 'invalid_time'):
|
||
v = mcp_server._parse_int(info.get(k) or '', 0)
|
||
if v:
|
||
out[k] = v
|
||
return out or None
|
||
|
||
|
||
def _extract_content(local_id, local_type, content, ct, chat_username, chat_display_name):
|
||
"""Return (rendered_text, extras_dict). Either may be None.
|
||
|
||
extras carries structured fields for non-text message types where caller
|
||
wants more than the human-readable string (currently: transfer). Future
|
||
additions (video号 metadata, merged-forward expansion, …) can flow through
|
||
the same channel without changing the caller signature.
|
||
"""
|
||
content = mcp_server._decompress_content(content, ct)
|
||
if content is None:
|
||
return None, None
|
||
|
||
# 群消息的 content 形如 'wxid_xxx:\n<xml...>'。Issue #88: 之前直接把
|
||
# 带前缀的字符串喂给 XML 解析器,群里的引用回复 / 卡片 / 视频等都因
|
||
# 解析失败导致 type 渲染成 link_or_file 且 content 为空。
|
||
is_group = bool(chat_username) and chat_username.endswith('@chatroom')
|
||
if is_group:
|
||
_, content = mcp_server._parse_message_content(content, local_type, True)
|
||
|
||
# names 用于群引用回复的发送者名解析(_resolve_quote_sender_label)。
|
||
# 1-on-1 场景也能用到(按 wxid 查显示名)。
|
||
names = mcp_server.get_contact_names()
|
||
|
||
base, _ = mcp_server._split_msg_type(local_type)
|
||
if base == 1:
|
||
return (content or ""), None
|
||
if base == 43:
|
||
return _format_video_message(content), None
|
||
if base == 47:
|
||
return _format_sticker_message(content), None
|
||
if base == 49:
|
||
rendered = mcp_server._format_app_message_text(
|
||
content, local_type, is_group, chat_username, chat_display_name, names
|
||
)
|
||
transfer = _extract_transfer_extras(content)
|
||
extras = {'type': 'transfer', 'transfer': transfer} if transfer else None
|
||
return rendered, extras
|
||
if base == 50:
|
||
return mcp_server._format_voip_message_text(content), None
|
||
if base == 10000:
|
||
return _format_system_message(content), None
|
||
if base == 10002:
|
||
return "[撤回消息]", None
|
||
return None, None
|