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:
@@ -167,6 +167,17 @@ def _extract_content(local_id, local_type, content, ct, chat_username, chat_disp
|
|||||||
if content is None:
|
if content is None:
|
||||||
return None, 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)
|
base, _ = mcp_server._split_msg_type(local_type)
|
||||||
if base == 1:
|
if base == 1:
|
||||||
return (content or ""), None
|
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
|
return _format_sticker_message(content), None
|
||||||
if base == 49:
|
if base == 49:
|
||||||
rendered = mcp_server._format_app_message_text(
|
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)
|
transfer = _extract_transfer_extras(content)
|
||||||
extras = {'type': 'transfer', 'transfer': transfer} if transfer else None
|
extras = {'type': 'transfer', 'transfer': transfer} if transfer else None
|
||||||
|
|||||||
104
tests/test_chat_export_helpers.py
Normal file
104
tests/test_chat_export_helpers.py
Normal 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()
|
||||||
Reference in New Issue
Block a user