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