Files
zWorkFlow/tests/test_refer_message.py
Belugary a6cb3d0497 feat: 解析微信引用回复消息 (appmsg type=57) + 新增 decode_refer MCP 工具
> 高价值改动 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 (引用回复渲染乱码)
2026-05-14 15:37:53 +08:00

220 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""微信引用回复消息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 全部不再泄漏到聊天历史。
合成 fixturewxid_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 = (
'&lt;msg&gt;&lt;img cdnurl="leak" aeskey="leak" md5="leak"/&gt;&lt;/msg&gt;'
)
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 = '&lt;msg&gt;&lt;img cdnurl="leak_main"/&gt;&lt;/msg&gt;'
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()