feat(chat): 合并转发聊天记录支持预览与弹窗查看

- appmsg(type=19) 解析为 renderType=chatHistory,并透传 recordItem(recorditem 原文)
- 修复 recorditem CDATA 内包含 <refermsg> 时误判为引用消息的问题

- 列表/导出路径统一带上 recordItem,并避免已解析的 appmsg 被二次 XML 解析覆盖
- 前端聊天页新增聊天记录卡片 + 弹窗,支持按条展示及图片/视频/引用内容预览
- 会话列表与摘要统一显示为 [聊天记录]
This commit is contained in:
2977094657
2026-01-01 16:30:05 +08:00
parent c1712ba6dd
commit d37131bf96
4 changed files with 729 additions and 16 deletions

View File

@@ -890,6 +890,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
record_item = ""
image_md5 = ""
image_file_id = ""
emoji_md5 = ""
@@ -929,6 +930,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
amount = str(parsed.get("amount") or "")
@@ -1089,14 +1091,17 @@ def _parse_message_for_export(
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
parsed_special = False
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
parsed_special = True
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
@@ -1121,9 +1126,11 @@ def _parse_message_for_export(
)
if not content_text:
content_text = transfer_status or "转账"
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not parsed_special:
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
@@ -1151,6 +1158,7 @@ def _parse_message_for_export(
"content": content_text,
"title": title,
"url": url,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,
"imageFileId": image_file_id,

View File

@@ -171,7 +171,7 @@ def _infer_last_message_brief(msg_type: Optional[int], sub_type: Optional[int])
if s == 2003:
return "[Red Packet]"
if s == 19:
return "[Chat History]"
return "[聊天记录]"
return "[App Message]"
if t == 10000:
return "[System]"
@@ -209,7 +209,7 @@ def _infer_message_brief_by_local_type(local_type: Optional[int]) -> str:
if t == 8594229559345:
return "[Red Packet]"
if t == 81604378673:
return "[Chat History]"
return "[聊天记录]"
if t == 266287972401:
return "[Pat]"
if t == 8589934592049:
@@ -698,6 +698,22 @@ def _parse_app_message(text: str) -> dict[str, Any]:
lower = text.lower()
if app_type == 19:
# 合并转发聊天记录Chat History
# 注意recorditem 的 CDATA 内部可能包含 <refermsg> 等标签,不能据此把整条消息误判为引用消息。
record_item = _extract_xml_tag_text(text, "recorditem")
preview = (des or "").strip()
if not preview:
if record_item:
preview = str(_extract_xml_tag_text(record_item, "desc") or "").strip()
return {
"renderType": "chatHistory",
"content": preview or "[聊天记录]",
"title": (title or "").strip() or "聊天记录",
"recordItem": record_item or "",
}
if app_type in (5, 68) and url:
thumb_url = _extract_xml_tag_text(text, "thumburl")
return {
@@ -724,7 +740,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"fileMd5": file_md5 or "",
}
if app_type == 57 or "<refermsg" in lower:
refermsg_probe = lower
if "<recorditem" in lower and "<refermsg" in lower:
# 合并转发聊天记录/其它 appmsg 里可能在 recorditem CDATA 内包含 refermsg
# 需要先剔除 recorditem 再判断是否为真正的引用消息。
try:
refermsg_probe = re.sub(
r"(<recorditem[^>]*>.*?</recorditem>)",
"",
text,
flags=re.IGNORECASE | re.DOTALL,
).lower()
except Exception:
refermsg_probe = lower
if app_type == 57 or "<refermsg" in refermsg_probe:
refer_block = _extract_refermsg_block(text)
try:
@@ -944,6 +974,8 @@ def _build_latest_message_preview(
rt = str(parsed.get("renderType") or "")
content_text = str(parsed.get("content") or "")
title_text = str(parsed.get("title") or "").strip()
if rt == "chatHistory":
content_text = "[聊天记录]"
if rt == "file" and title_text:
content_text = title_text
if (not content_text) and rt == "transfer":

View File

@@ -373,6 +373,7 @@ def _append_full_messages_from_rows(
content_text = raw_text
title = ""
url = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
emoji_url = ""
@@ -414,6 +415,7 @@ def _append_full_messages_from_rows(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
quote_username = str(parsed.get("quoteUsername") or "")
@@ -606,14 +608,17 @@ def _append_full_messages_from_rows(
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
parsed_special = False
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
parsed_special = True
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
@@ -639,9 +644,11 @@ def _append_full_messages_from_rows(
)
if not content_text:
content_text = transfer_status or "转账"
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not parsed_special:
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
@@ -664,6 +671,7 @@ def _append_full_messages_from_rows(
"content": content_text,
"title": title,
"url": url,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
"emojiMd5": emoji_md5,
@@ -1080,6 +1088,19 @@ async def list_chat_sessions(
else:
last_message = _infer_last_message_brief(r["last_msg_type"], r["last_msg_sub_type"])
# 合并转发聊天记录:左侧会话列表统一显示为 [聊天记录]
if preview_mode != "none" and not str(last_message or "").startswith("[草稿]"):
try:
last_msg_type = int(r["last_msg_type"] or 0)
except Exception:
last_msg_type = 0
try:
last_msg_sub_type = int(r["last_msg_sub_type"] or 0)
except Exception:
last_msg_sub_type = 0
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
last_message = "[聊天记录]"
last_time = _format_session_time(r["sort_timestamp"] or r["last_timestamp"])
sessions.append(
@@ -1214,6 +1235,7 @@ def _collect_chat_messages(
content_text = raw_text
title = ""
url = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
emoji_url = ""
@@ -1257,6 +1279,7 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
quote_username = str(parsed.get("quoteUsername") or "")
@@ -1444,14 +1467,17 @@ def _collect_chat_messages(
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
parsed_special = False
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
parsed_special = True
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
@@ -1477,9 +1503,11 @@ def _collect_chat_messages(
)
if not content_text:
content_text = transfer_status or "转账"
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not parsed_special:
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
@@ -1509,6 +1537,7 @@ def _collect_chat_messages(
"content": content_text,
"title": title,
"url": url,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
"emojiMd5": emoji_md5,
@@ -1746,6 +1775,7 @@ async def list_chat_messages(
content_text = raw_text
title = ""
url = ""
record_item = ""
image_md5 = ""
emoji_md5 = ""
emoji_url = ""
@@ -1789,6 +1819,7 @@ async def list_chat_messages(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
quote_username = str(parsed.get("quoteUsername") or "")
@@ -1976,14 +2007,17 @@ async def list_chat_messages(
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
parsed_special = False
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
parsed_special = True
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
@@ -2009,9 +2043,11 @@ async def list_chat_messages(
)
if not content_text:
content_text = transfer_status or "转账"
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not parsed_special:
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
@@ -2034,6 +2070,7 @@ async def list_chat_messages(
"content": content_text,
"title": title,
"url": url,
"recordItem": record_item,
"imageMd5": image_md5,
"imageFileId": image_file_id,
"emojiMd5": emoji_md5,