> 高价值改动 rationale (override 路径) > > 引用回复 (appmsg type=57) 是聊天里第 3 高频的消息类型 (仅次于纯文本和 > 图片)。当前 _format_app_message_text 的 type=57 分支直接把 refermsg/ > content 按 [:160] 截断当摘要,对内层 type=3 (图片) / 34 (语音) / > 43 (视频) / 47 (动画表情) / 49 (嵌套卡片) 这些"二进制"被引用消息, > 会把 cdnurl / aeskey / md5 / cdnthumb / voiceurl / externurl 一坨乱码 > 渲染到 LLM 可见的 chat history,严重污染上下文。issue #44 #45 重复反馈 > 一个月无人接 —— 这是个明确的用户痛点,fork 实测覆盖 5 种内层 type 的真 > 实数据,渲染长度从原本几千字降到 21-58 字。改动较大但 review 风险低: > 替换的就是 19 行 inline 截断逻辑,新加的 helpers / decode_refer 都是 > 纯加,不动现有 API。 \`_format_app_message_text\` 当前 type=57 分支用 19 行 inline 逻辑直接 \`refer.findtext('content')[:160]\` 当摘要。这对 type=1 (文本) 工作正常, 但对其他内层 type 是个隐藏的 bug: - type=3 图片: 渲染 \`<msg><img cdnthumburl="…" aeskey="…" md5="…" cdnurl="…" />\` 截断 - type=34 语音: 渲染 \`<voicemsg voicelength="…" voiceurl="…" />\` 截断 - type=43 视频: 渲染 \`<videomsg cdnvideourl="…" cdnthumburl="…" />\` 截断 - type=47 动画表情: 渲染 \`<emoji md5="…" externurl="…" />\` 截断 - type=49 嵌套卡片: 渲染外层 escape 后的 XML 字符串截断 后果: cdnurl / aeskey / md5 / voiceurl / externurl 等二进制元数据泄漏到 LLM 可见的聊天历史,污染上下文且无信息量。引用回复是 type=57 是高频消息,影响面大。 按 refer_type 分发 schema-aware 摘要: 1. **新增三组 helpers (mcp_server.py +135 行,纯加)**: - \`_REFER_INNER_TYPE_LABEL\`: 内层 type → 中文标签 (1 文本 / 3 图片 / 34 语音 / ...) - \`_INNER_APPMSG_TYPE_LABEL\`: refer_type=49 时嵌套 appmsg/type → 标签 (5 链接 / 6 文件 / 19 聊天记录 / ...) - \`_extract_refer_info(appmsg)\`: 提取 refermsg 全字段返回 dict - \`_summarize_refer_content(refer_type, content)\`: 按 type 分支 - type=1: 取原文,截断到 max_len - type=3/34/43/47/...: 给标签,**不**展开 cdnurl/aeskey/md5 - type=49: 走 \`_parse_xml_root\` (经 \`_XML_UNSAFE_RE\` 过滤 DOCTYPE/ENTITY 防 XXE) 解一层 inner appmsg, 给 \`[链接] xxx\` - 未识别 type: 给 \`[type=N]\` 兜底 - \`_format_refer_message_text(appmsg, ...)\`: 渲染两行格式 \`<回复正文>\n ↳ 回复 <对方>: <摘要>\` 2. **\`_format_app_message_text\` 的 type=57 分支简化**: 19 行 inline → 3 行 dispatch 到 helper。 3. **新增 MCP 工具 \`decode_refer(chat_name, local_id, create_time=0)\`**: 输出结构化多行文本 (回复正文 / 被引用发送者 / 类型 / 摘要 / svrid / createtime), 错误文案分别指引 \`decode_file_message\` (type=6) / \`decode_record_item\` (type=19) / \`decode_transfer\` (type=2000), 不让用户在 4 个工具间盲猜。 新文件 \`tests/test_refer_message.py\`, 20 个新测试: - \`ReferInnerTypeLabelTests\` (2): 标签映射 spot-check - \`ExtractReferInfoTests\` (2): 全字段提取 / refermsg 缺失返回 None - \`SummarizeReferContentTests\` (11): 5 种 refer_type 标签 / type=1 文本截断 / type=49 嵌套链接卡 / type=49 聊天记录卡 / type=49 invalid XML 退化 / unknown type 兜底 / 空 content / XXE payload 拒绝 - \`FormatReferMessageTextTests\` (4): 1v1 文本引用渲染 / 图片引用不泄漏 PII (cdnurl/aeskey/md5) / refermsg 缺失退回 title / 空 reply 用占位符 - \`AppMessageDispatchReferTests\` (1): dispatcher 走新 helper 不走旧截断 合成 fixture (wxid_synth_a/b, 12345@chatroom, Sender A/B, svrid 1+0\*18), 无真实 PII。 基线 183 → 203 通过 (+20 新增), 0 回归。 - \`mcp_server.py\`: 替换 19 行 type=57 inline → 3 行 dispatch (净 -16 行); 新增 6 个 helpers + 1 个 MCP 工具 \`decode_refer\` (+275 行); 不改任何现有公开 API。 - \`tests/test_refer_message.py\`: 新增 (20 测试, 合成 fixture, 不依赖真实加密素材)。 - **本 PR 不包含 fork 里的 CLI 入口 (\`wxdec.cli.decode_refer\`) 和 \`export_chat\` / \`monitor_web\` 的对应改动** —— 那几处依赖 fork 私有的包结构 (\`wxdec/cli/\`), 不属于上游 scope。后续如有需要可单独提。 issue #44 #45 (引用回复渲染乱码)
220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
"""微信引用回复消息(appmsg type=57)解析鉴定测试。
|
||
|
||
旧逻辑直接把 refermsg/content 按 [:160] 截断当摘要,对 type=3 (图片) /
|
||
34 (语音) / 47 (动画表情) / 49 (嵌套卡片) 这些"二进制"被引用消息会渲染
|
||
成 cdnurl + aeskey + md5 一坨乱码 (issue #44 #45)。本组测试 pin 新行为:
|
||
按 refer_type 给 schema-aware 摘要,cdnurl / aeskey / md5 / cdnthumb /
|
||
voiceurl / externurl 全部不再泄漏到聊天历史。
|
||
|
||
合成 fixture:wxid_synth_a / wxid_synth_b / 12345@chatroom / Sender A/B /
|
||
svrid 1 + 0*18,无真实 PII。
|
||
"""
|
||
import unittest
|
||
import xml.etree.ElementTree as ET
|
||
|
||
import mcp_server
|
||
|
||
|
||
# ---------- 合成 fixture ----------
|
||
|
||
def _appmsg(refermsg_xml='', title='我的回复'):
|
||
"""组装一个最小 type=57 appmsg 元素。"""
|
||
xml = (
|
||
f'<msg><appmsg><type>57</type><title>{title}</title>'
|
||
f'{refermsg_xml}</appmsg></msg>'
|
||
)
|
||
root = ET.fromstring(xml)
|
||
return root.find('.//appmsg')
|
||
|
||
|
||
def _refermsg(refer_type, content, fromusr='wxid_synth_a',
|
||
displayname='Sender A', svrid='1' + '0' * 18,
|
||
chatusr='', createtime='1700000000'):
|
||
return (
|
||
'<refermsg>'
|
||
f'<type>{refer_type}</type>'
|
||
f'<svrid>{svrid}</svrid>'
|
||
f'<fromusr>{fromusr}</fromusr>'
|
||
f'<chatusr>{chatusr}</chatusr>'
|
||
f'<displayname>{displayname}</displayname>'
|
||
f'<createtime>{createtime}</createtime>'
|
||
f'<content>{content}</content>'
|
||
'</refermsg>'
|
||
)
|
||
|
||
|
||
# ---------- 标签映射 ----------
|
||
|
||
class ReferInnerTypeLabelTests(unittest.TestCase):
|
||
def test_known_refer_inner_labels(self):
|
||
self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['3'], '图片')
|
||
self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['34'], '语音')
|
||
self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['47'], '动画表情')
|
||
self.assertEqual(mcp_server._REFER_INNER_TYPE_LABEL['49'], '链接/卡片')
|
||
|
||
def test_known_inner_appmsg_labels(self):
|
||
self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['5'], '链接')
|
||
self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['6'], '文件')
|
||
self.assertEqual(mcp_server._INNER_APPMSG_TYPE_LABEL['19'], '聊天记录')
|
||
|
||
|
||
# ---------- _extract_refer_info ----------
|
||
|
||
class ExtractReferInfoTests(unittest.TestCase):
|
||
def test_full_fields_round_trip(self):
|
||
appmsg = _appmsg(_refermsg('1', '原文本'), title='回复正文')
|
||
info = mcp_server._extract_refer_info(appmsg)
|
||
self.assertEqual(info['reply_text'], '回复正文')
|
||
self.assertEqual(info['refer_type'], '1')
|
||
self.assertEqual(info['refer_fromusr'], 'wxid_synth_a')
|
||
self.assertEqual(info['refer_displayname'], 'Sender A')
|
||
self.assertEqual(info['refer_svrid'], '1' + '0' * 18)
|
||
self.assertEqual(info['refer_content'], '原文本')
|
||
|
||
def test_missing_refermsg_returns_none(self):
|
||
appmsg = _appmsg(refermsg_xml='', title='孤儿回复')
|
||
self.assertIsNone(mcp_server._extract_refer_info(appmsg))
|
||
|
||
|
||
# ---------- _summarize_refer_content ----------
|
||
|
||
class SummarizeReferContentTests(unittest.TestCase):
|
||
def test_text_returns_original(self):
|
||
self.assertEqual(mcp_server._summarize_refer_content('1', '你好'), '你好')
|
||
|
||
def test_text_truncates_to_max_len(self):
|
||
long = '中' * 200
|
||
out = mcp_server._summarize_refer_content('1', long, max_len=160)
|
||
self.assertEqual(len(out), 161) # 160 + '…'
|
||
self.assertTrue(out.endswith('…'))
|
||
|
||
def test_image_returns_label_not_xml(self):
|
||
v2_image_xml = (
|
||
'<msg><img cdnthumburl="http://cdn.example/leak_thumb" '
|
||
'aeskey="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" '
|
||
'md5="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" '
|
||
'cdnurl="http://cdn.example/leak_main" /></msg>'
|
||
)
|
||
out = mcp_server._summarize_refer_content('3', v2_image_xml)
|
||
self.assertEqual(out, '[图片]')
|
||
# PII / 二进制元数据不能泄漏到摘要
|
||
for leak in ('cdnurl', 'aeskey', 'md5', 'cdnthumb', 'leak_main'):
|
||
self.assertNotIn(leak, out)
|
||
|
||
def test_voice_returns_label(self):
|
||
v_xml = '<msg><voicemsg voicelength="3300" '\
|
||
'voiceurl="http://cdn.example/leak.silk" /></msg>'
|
||
out = mcp_server._summarize_refer_content('34', v_xml)
|
||
self.assertEqual(out, '[语音]')
|
||
self.assertNotIn('voiceurl', out)
|
||
|
||
def test_emoji_returns_label(self):
|
||
out = mcp_server._summarize_refer_content(
|
||
'47', '<msg><emoji md5="xx" externurl="leak.gif"/></msg>'
|
||
)
|
||
self.assertEqual(out, '[动画表情]')
|
||
self.assertNotIn('externurl', out)
|
||
self.assertNotIn('leak', out)
|
||
|
||
def test_nested_link_card_summary(self):
|
||
nested = '<msg><appmsg><type>5</type><title>分享标题</title>'\
|
||
'<url>http://example.com/leak</url></appmsg></msg>'
|
||
out = mcp_server._summarize_refer_content('49', nested)
|
||
self.assertEqual(out, '[链接] 分享标题')
|
||
self.assertNotIn('http', out)
|
||
self.assertNotIn('url', out)
|
||
|
||
def test_nested_record_card_summary(self):
|
||
nested = '<msg><appmsg><type>19</type><title>群聊天记录</title></appmsg></msg>'
|
||
out = mcp_server._summarize_refer_content('49', nested)
|
||
self.assertEqual(out, '[聊天记录] 群聊天记录')
|
||
|
||
def test_nested_invalid_xml_falls_back_to_card(self):
|
||
self.assertEqual(
|
||
mcp_server._summarize_refer_content('49', '<msg><appmsg'),
|
||
'[卡片]',
|
||
)
|
||
|
||
def test_unknown_refer_type_falls_back(self):
|
||
out = mcp_server._summarize_refer_content('999', 'irrelevant')
|
||
self.assertEqual(out, '[type=999]')
|
||
|
||
def test_empty_content_with_known_type(self):
|
||
self.assertEqual(mcp_server._summarize_refer_content('3', ''), '[图片]')
|
||
|
||
def test_xxe_payload_rejected_in_nested(self):
|
||
xxe = (
|
||
'<!DOCTYPE foo [<!ENTITY x SYSTEM "file:///etc/passwd">]>'
|
||
'<msg><appmsg><type>5</type><title>&x;</title></appmsg></msg>'
|
||
)
|
||
out = mcp_server._summarize_refer_content('49', xxe)
|
||
self.assertEqual(out, '[卡片]')
|
||
|
||
|
||
# ---------- _format_refer_message_text ----------
|
||
|
||
class FormatReferMessageTextTests(unittest.TestCase):
|
||
def _names(self):
|
||
return {'wxid_synth_a': 'Sender A', 'wxid_synth_b': 'Sender B'}
|
||
|
||
def test_text_refer_in_1v1(self):
|
||
appmsg = _appmsg(_refermsg('1', '你吃了吗'), title='吃了')
|
||
out = mcp_server._format_refer_message_text(
|
||
appmsg, is_group=False, chat_username='wxid_synth_a',
|
||
chat_display_name='Sender A', names=self._names(),
|
||
)
|
||
self.assertEqual(out, '吃了\n ↳ 回复 Sender A: 你吃了吗')
|
||
|
||
def test_image_refer_uses_label_not_xml_payload(self):
|
||
v2_image = (
|
||
'<msg><img cdnurl="leak" aeskey="leak" md5="leak"/></msg>'
|
||
)
|
||
appmsg = _appmsg(_refermsg('3', v2_image), title='这张?')
|
||
out = mcp_server._format_refer_message_text(
|
||
appmsg, is_group=False, chat_username='wxid_synth_a',
|
||
chat_display_name='Sender A', names=self._names(),
|
||
)
|
||
self.assertIn('[图片]', out)
|
||
for leak in ('cdnurl', 'aeskey', 'md5'):
|
||
self.assertNotIn(leak, out)
|
||
|
||
def test_missing_refermsg_falls_back_to_title(self):
|
||
appmsg = _appmsg(refermsg_xml='', title='孤儿回复')
|
||
out = mcp_server._format_refer_message_text(
|
||
appmsg, is_group=False, chat_username='wxid_synth_a',
|
||
chat_display_name='Sender A', names={},
|
||
)
|
||
self.assertEqual(out, '孤儿回复')
|
||
|
||
def test_empty_reply_uses_placeholder(self):
|
||
appmsg = _appmsg(_refermsg('1', 'hi'), title='')
|
||
out = mcp_server._format_refer_message_text(
|
||
appmsg, is_group=False, chat_username='wxid_synth_a',
|
||
chat_display_name='Sender A', names=self._names(),
|
||
)
|
||
self.assertTrue(out.startswith('[引用消息]'))
|
||
|
||
|
||
# ---------- 调度入口 ----------
|
||
|
||
class AppMessageDispatchReferTests(unittest.TestCase):
|
||
def test_type57_dispatches_to_helper(self):
|
||
# _format_app_message_text 的 type=57 分支必须走 _format_refer_message_text,
|
||
# 不再走旧的 inline [:160] 截断。
|
||
v2_image = '<msg><img cdnurl="leak_main"/></msg>'
|
||
content = (
|
||
f'<msg><appmsg><type>57</type><title>看这个</title>'
|
||
f'{_refermsg("3", v2_image)}</appmsg></msg>'
|
||
)
|
||
out = mcp_server._format_app_message_text(
|
||
content, local_type=49, is_group=False,
|
||
chat_username='wxid_synth_a', chat_display_name='Sender A', names={},
|
||
)
|
||
self.assertIn('[图片]', out)
|
||
self.assertNotIn('leak_main', out)
|
||
self.assertNotIn('cdnurl', out)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|