From 70d44ef61f3129345e343cdde30de71e240d307a Mon Sep 17 00:00:00 2001 From: joshua-deng Date: Wed, 13 May 2026 13:40:17 +0800 Subject: [PATCH] fix(export): strip group prefix before parsing appmsg in chat export (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- chat_export_helpers.py | 13 +++- tests/test_chat_export_helpers.py | 104 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/test_chat_export_helpers.py diff --git a/chat_export_helpers.py b/chat_export_helpers.py index 5c2794c..dfbca46 100644 --- a/chat_export_helpers.py +++ b/chat_export_helpers.py @@ -167,6 +167,17 @@ def _extract_content(local_id, local_type, content, ct, chat_username, chat_disp if content is None: return None, None + # 群消息的 content 形如 'wxid_xxx:\n'。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 @@ -176,7 +187,7 @@ def _extract_content(local_id, local_type, content, ct, chat_username, chat_disp return _format_sticker_message(content), None if base == 49: rendered = mcp_server._format_app_message_text( - content, local_type, False, chat_username, chat_display_name, {} + 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 diff --git a/tests/test_chat_export_helpers.py b/tests/test_chat_export_helpers.py new file mode 100644 index 0000000..e532879 --- /dev/null +++ b/tests/test_chat_export_helpers.py @@ -0,0 +1,104 @@ +"""Tests for `chat_export_helpers._extract_content` group prefix handling. + +Issue #88: 群聊里的引用回复 / appmsg 卡片在 export_chat / export_all_chats +渲染成 link_or_file 且 content 为空。根因是 `_extract_content` 把带 +`wxid_xxx:\\n` 群前缀的原始 content 直接喂给 `_format_app_message_text`, +XML 解析器在前缀文本上崩溃。 + +修复后: +- 检测到 chat_username 是 @chatroom,先用 `_parse_message_content` 剥前缀 +- 把 `is_group=True` 透传给 `_format_app_message_text` 让引用回复的发送者 + 标签解析走群路径 +- 用真实的 contact names dict 而不是 `{}` 让 1-on-1 也能解出昵称 +""" +import unittest +from unittest.mock import patch + +import chat_export_helpers +import mcp_server + + +def _refer_appmsg(refer_content="hello world"): + """合成一条引用回复 appmsg。""" + return ( + '' + 'quote reply' + '57' + '' + '1' + f'{refer_content}' + 'wxid_orig_sender' + 'Original Sender' + '' + '' + ) + + +class ExtractContentGroupPrefixTests(unittest.TestCase): + def setUp(self): + # Skip decompression + self._patch = patch.object( + mcp_server, '_decompress_content', + side_effect=lambda content, ct: content, + ) + self._patch.start() + self._names_patch = patch.object( + mcp_server, 'get_contact_names', + return_value={'wxid_orig_sender': 'Alice'}, + ) + self._names_patch.start() + + def tearDown(self): + self._patch.stop() + self._names_patch.stop() + + def test_group_appmsg_with_prefix_renders_correctly(self): + """Issue #88: 群引用回复带 'wxid_xxx:\\n' 前缀,需要正确剥离后再解析。""" + prefixed = 'wxid_group_member:\n' + _refer_appmsg('hello group') + rendered, extras = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=prefixed, ct=0, + chat_username='12345@chatroom', chat_display_name='Test Group', + ) + self.assertIsNotNone(rendered, "群引用回复不应该解析失败返回 None") + self.assertIn('quote reply', rendered) + self.assertIn('hello group', rendered, "被引用内容应该出现在渲染结果里") + + def test_one_on_one_appmsg_unaffected(self): + """1-on-1 场景没有前缀,行为应该保持不变。""" + rendered, _ = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=_refer_appmsg('hi'), ct=0, + chat_username='wxid_friend', chat_display_name='Friend', + ) + self.assertIsNotNone(rendered) + self.assertIn('hi', rendered) + + def test_group_text_prefix_stripped(self): + """群里的 base=1 text 消息,content 也带前缀,应该被剥掉。""" + text, _ = chat_export_helpers._extract_content( + local_id=100, local_type=1, content='wxid_xx:\nhello group', + ct=0, chat_username='12345@chatroom', chat_display_name='Group', + ) + self.assertEqual(text, 'hello group') + + def test_one_on_one_text_unaffected(self): + """1-on-1 text 没有前缀概念,原样返回。""" + text, _ = chat_export_helpers._extract_content( + local_id=100, local_type=1, content='hello friend', ct=0, + chat_username='wxid_friend', chat_display_name='Friend', + ) + self.assertEqual(text, 'hello friend') + + def test_group_quote_uses_real_names(self): + """群引用回复的发送者标签应该用真实 contact names 解析。""" + prefixed = 'wxid_group_member:\n' + _refer_appmsg() + rendered, _ = chat_export_helpers._extract_content( + local_id=100, local_type=49, content=prefixed, ct=0, + chat_username='12345@chatroom', chat_display_name='Test Group', + ) + # is_group=True 走 group 分支:用 ref_user (wxid_orig_sender) 查 names + # → 'Alice'。原先 names={} 会回退到 displayname。 + self.assertIn('Alice', rendered, "应该用 names dict 解析出 'Alice'") + + +if __name__ == "__main__": + unittest.main()