fix(export): strip group prefix before parsing appmsg in chat export (#101)

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>
This commit is contained in:
joshua-deng
2026-05-13 13:40:17 +08:00
committed by GitHub
parent 8645fe4210
commit 70d44ef61f
2 changed files with 116 additions and 1 deletions

View File

@@ -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<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
@@ -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

View File

@@ -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 (
'<msg><appmsg appid="" sdkver="0">'
'<title>quote reply</title>'
'<type>57</type>'
'<refermsg>'
'<type>1</type>'
f'<content>{refer_content}</content>'
'<fromusr>wxid_orig_sender</fromusr>'
'<displayname>Original Sender</displayname>'
'</refermsg>'
'</appmsg></msg>'
)
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()