\n')
+
+ avatar_src = rel_path(str(msg.get("senderAvatarPath") or "").strip())
+ display_name = str(msg.get("senderDisplayName") or "").strip()
+ fallback_char = (display_name or sender_username or "?")[:1]
+ tw.write(" " + build_avatar_html(src=avatar_src, fallback_text=fallback_char, extra_class=avatar_extra) + "\n")
+
+ align_cls = "items-end" if is_sent else "items-start"
+ tw.write(f'
\n')
+ if conv_is_group and (not is_sent) and display_name:
+ tw.write(f'
{esc_text(display_name)}
\n')
+
+ pos_cls = "right-0" if is_sent else "left-0"
+ tw.write(
+ '
{esc_text(create_time_text)}
\n'
+ )
+
+ # Message body
+ bubble_dir_cls = "bg-[#95EC69] text-black bubble-tail-r" if is_sent else "bg-white text-gray-800 bubble-tail-l"
+ bubble_base_cls = "px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
+ bubble_unknown_cls = (
+ "px-3 py-2 text-xs max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed text-gray-700"
+ )
+
+ if rt == "image":
+ src = offline_path(msg, "image")
+ if not src:
+ url = str(msg.get("imageUrl") or "").strip()
+ src = url if is_http_url(url) else ""
+ if src:
+ tw.write('
\n')
+ tw.write('
\n")
+ tw.write("
\n")
+ else:
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ elif rt == "emoji":
+ src = offline_path(msg, "emoji")
+ if not src:
+ url = str(msg.get("emojiUrl") or "").strip()
+ src = url if is_http_url(url) else ""
+ if src:
+ emoji_dir = " flex-row-reverse" if is_sent else ""
+ tw.write(f'
\n')
+ tw.write(f'
})
\n')
+ tw.write("
\n")
+ else:
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ elif rt == "video":
+ thumb = offline_path(msg, "video_thumb")
+ if not thumb:
+ url = str(msg.get("videoThumbUrl") or "").strip()
+ thumb = url if is_http_url(url) else ""
+ video = offline_path(msg, "video")
+ if not video:
+ url = str(msg.get("videoUrl") or "").strip()
+ video = url if is_http_url(url) else ""
+ if thumb:
+ tw.write('
\n')
+ tw.write('
\n")
+ tw.write("
\n")
+ else:
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ elif rt == "voice":
+ voice = offline_path(msg, "voice")
+ if voice:
+ duration_ms = msg.get("voiceLength")
+ width = get_voice_width(duration_ms)
+ seconds = get_voice_duration_in_seconds(duration_ms)
+ voice_dir_cls = "wechat-voice-sent" if is_sent else "wechat-voice-received"
+ content_dir_cls = " flex-row-reverse" if is_sent else ""
+ icon_dir_cls = "voice-icon-sent" if is_sent else "voice-icon-received"
+ voice_id = str(msg.get("id") or "").strip()
+
+ tw.write('
\n')
+ tw.write(
+ f'
\n'
+ )
+ tw.write(f'
\n')
+ tw.write(
+ f'
\n")
+ tw.write(f'
{esc_text(seconds)}"\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write(f'
\n')
+ tw.write("
\n")
+ else:
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ elif rt == "file":
+ fsrc = offline_path(msg, "file")
+ title = str(msg.get("title") or msg.get("content") or "文件").strip()
+ size = str(msg.get("fileSize") or "").strip()
+ size_text = format_file_size(size)
+ sent_side_cls = " wechat-special-sent-side" if is_sent else ""
+ cls = f"wechat-redpacket-card wechat-special-card wechat-file-card msg-radius{sent_side_cls}"
+ tag = "a" if fsrc else "div"
+ attrs = f' href="{esc_attr(fsrc)}" download' if fsrc else ""
+ tw.write(f' <{tag}{attrs} class="{esc_attr(cls)}">\n')
+ tw.write('
\n')
+ tw.write('
\n')
+ tw.write(f' {esc_text(title or "文件")}\n')
+ if size_text:
+ tw.write(f' {esc_text(size_text)}\n')
+ tw.write("
\n")
+ tw.write(f'
)})
\n')
+ tw.write("
\n")
+ tw.write('
\n')
+ tw.write(f'

\n')
+ tw.write("
微信电脑版\n")
+ tw.write("
\n")
+ tw.write(f" {tag}>\n")
+ elif rt == "link":
+ url = str(msg.get("url") or "").strip()
+ safe_url = url if is_http_url(url) else ""
+ if safe_url:
+ heading = str(msg.get("title") or msg.get("content") or safe_url).strip()
+ abstract = str(msg.get("content") or "").strip()
+ preview = str(msg.get("thumbUrl") or "").strip()
+ preview_url = ""
+ if is_http_url(preview):
+ local = maybe_download_remote_image(preview)
+ preview_url = local or preview
+ variant = str(msg.get("linkStyle") or "").strip().lower()
+
+ from_text = get_link_from_text(msg, url=safe_url)
+ from_avatar_text = first_glyph(from_text) or "\u200B"
+ from_text = from_text or "\u200B"
+ sent_side_cls = " wechat-special-sent-side" if is_sent else ""
+
+ if variant == "cover":
+ cls = f"wechat-link-card-cover wechat-special-card msg-radius{sent_side_cls}"
+ tw.write(
+ f'
\n'
+ )
+ if preview_url:
+ tw.write(' \n')
+ tw.write(
+ f'
})
\n'
+ )
+ tw.write('
\n')
+ tw.write(
+ f'
{esc_text(from_avatar_text)}
\n'
+ )
+ tw.write(f'
{esc_text(from_text)}
\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ else:
+ tw.write(' \n')
+ tw.write(
+ f'
{esc_text(from_avatar_text)}
\n'
+ )
+ tw.write(f'
{esc_text(from_text)}
\n')
+ tw.write("
\n")
+ tw.write(f' {esc_text(heading or safe_url)}
\n')
+ tw.write(" \n")
+ else:
+ cls = f"wechat-link-card wechat-special-card msg-radius{sent_side_cls}"
+ tw.write(
+ f'
\n'
+ )
+ tw.write(' \n')
+ tw.write('
\n')
+ tw.write(f'
{esc_text(heading or safe_url)}
\n')
+ if abstract:
+ tw.write(f'
{esc_text(abstract)}
\n')
+ tw.write("
\n")
+ if preview_url:
+ tw.write('
\n')
+ tw.write(
+ f'
})
\n'
+ )
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write(' \n')
+ tw.write(
+ f'
{esc_text(from_avatar_text)}
\n'
+ )
+ tw.write(f'
{esc_text(from_text)}
\n')
+ tw.write("
\n")
+ tw.write(" \n")
+ else:
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ elif rt == "voip":
+ voip_dir_cls = "wechat-voip-sent" if is_sent else "wechat-voip-received"
+ content_dir_cls = " flex-row-reverse" if is_sent else ""
+ voip_type = str(msg.get("voipType") or "").strip().lower()
+ icon = "wechat-video-light.png" if voip_type == "video" else "wechat-audio-light.png"
+ tw.write(f'
\n')
+ tw.write(f'
\n')
+ tw.write(f'
)})
\n')
+ tw.write(f'
{esc_text(msg.get("content") or "通话")}\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ elif rt == "quote":
+ tw.write(
+ f'
{render_text_with_emojis(msg.get("content") or "")}
\n'
+ )
+
+ qt = str(msg.get("quoteTitle") or "").strip()
+ qc = str(msg.get("quoteContent") or "").strip()
+ qthumb = str(msg.get("quoteThumbUrl") or "").strip()
+ qtype = str(msg.get("quoteType") or "").strip()
+ qsid_raw = str(msg.get("quoteServerId") or "").strip()
+ qsid = int(qsid_raw) if qsid_raw.isdigit() else 0
+
+ def is_quoted_voice() -> bool:
+ if qtype == "34":
+ return True
+ return (qc == "[语音]") and bool(qsid_raw)
+
+ def is_quoted_image() -> bool:
+ if qtype == "3":
+ return True
+ return (qc == "[图片]") and bool(qsid_raw)
+
+ def is_quoted_link() -> bool:
+ if qtype == "49":
+ return True
+ return bool(re.match(r"^\[链接\]\s*", qc))
+
+ def get_quoted_link_text() -> str:
+ if not qc:
+ return ""
+ return re.sub(r"^\[链接\]\s*", "", qc).strip() or qc
+
+ quoted_voice = is_quoted_voice()
+ quoted_image = is_quoted_image()
+ quoted_link = is_quoted_link()
+
+ quote_voice_url = ""
+ if include_media and ("voice" in media_kinds) and quoted_voice and qsid:
+ try:
+ arc, is_new = _materialize_voice(
+ zf=zf,
+ media_db_path=media_db_path,
+ server_id=int(qsid),
+ media_written=media_written,
+ )
+ except Exception:
+ arc, is_new = "", False
+ if arc:
+ quote_voice_url = rel_path(arc)
+ if is_new:
+ with lock:
+ job.progress.media_copied += 1
+
+ quote_image_url = ""
+ if include_media and ("image" in media_kinds) and quoted_image and qsid and resource_conn is not None:
+ md5_hit = ""
+ try:
+ md5_hit = _lookup_resource_md5(
+ resource_conn,
+ resource_chat_id,
+ message_local_type=3,
+ server_id=int(qsid),
+ local_id=0,
+ create_time=0,
+ )
+ except Exception:
+ md5_hit = ""
+
+ if md5_hit:
+ try:
+ arc, is_new = _materialize_media(
+ zf=zf,
+ account_dir=account_dir,
+ conv_username=conv_username,
+ kind="image",
+ md5=str(md5_hit or "").strip().lower(),
+ file_id="",
+ media_written=media_written,
+ suggested_name="",
+ )
+ except Exception:
+ arc, is_new = "", False
+ if arc:
+ quote_image_url = rel_path(arc)
+ if is_new:
+ with lock:
+ job.progress.media_copied += 1
+
+ qthumb_url = ""
+ if is_http_url(qthumb):
+ qthumb_local = maybe_download_remote_image(qthumb) if download_remote_media else ""
+ qthumb_url = qthumb_local or qthumb
+
+ if qt or qc:
+ tw.write(
+ '
\n'
+ )
+ tw.write('
\n')
+ if quoted_voice:
+ seconds = get_voice_duration_in_seconds(msg.get("quoteVoiceLength"))
+ disabled = not bool(quote_voice_url)
+ btn_cls = "flex items-center gap-1 min-w-0 hover:opacity-80"
+ if disabled:
+ btn_cls += " opacity-60 cursor-not-allowed"
+ dis_attr = " disabled" if disabled else ""
+ tw.write('
\n')
+ if qt:
+ tw.write(f'
{esc_text(qt)}:\n')
+ tw.write(
+ f'
\n")
+ if quote_voice_url:
+ tw.write(
+ f'
\n'
+ )
+ tw.write("
\n")
+ else:
+ tw.write('
\n')
+ if quoted_link:
+ link_text = get_quoted_link_text()
+ tw.write('
\n')
+ if qt:
+ tw.write(f' {esc_text(qt)}:\n')
+ if link_text:
+ ml = ' class="ml-1"' if qt else ""
+ tw.write(f' 🔗 {esc_text(link_text)}\n')
+ tw.write("
\n")
+ else:
+ hide_qc = quoted_image and qt and bool(quote_image_url)
+ tw.write('
\n')
+ if qt:
+ tw.write(f' {esc_text(qt)}:\n')
+ if qc and (not hide_qc):
+ ml = ' class="ml-1"' if qt else ""
+ tw.write(f' {esc_text(qc)}\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write("
\n")
+
+ if quoted_link and qthumb_url:
+ tw.write(
+ f'
\n'
+ )
+ tw.write(
+ f'
\n'
+ )
+ tw.write(" \n")
+
+ if (not quoted_link) and quoted_image and quote_image_url:
+ tw.write(
+ f'
\n'
+ )
+ tw.write(
+ f'
\n'
+ )
+ tw.write(" \n")
+
+ tw.write("
\n")
+ elif rt == "chatHistory":
+ title = str(msg.get("title") or "").strip() or "合并消息"
+ record_item = str(msg.get("recordItem") or "").strip()
+ record_item_b64 = ""
+ if record_item:
+ try:
+ record_item_b64 = base64.b64encode(record_item.encode("utf-8", errors="replace")).decode("ascii")
+ except Exception:
+ record_item_b64 = ""
+
+ if record_item and include_media and (not privacy_mode):
+ try:
+ for m in _CHAT_HISTORY_MD5_TAG_RE.findall(record_item):
+ _ensure_chat_history_md5(m)
+ except Exception:
+ pass
+ if resource_conn is not None:
+ try:
+ server_map = page_media_index.get("serverMd5")
+ if not isinstance(server_map, dict):
+ server_map = {}
+ page_media_index["serverMd5"] = server_map
+
+ for sid_raw in _CHAT_HISTORY_SERVER_ID_TAG_RE.findall(record_item):
+ sid_text = str(sid_raw or "").strip()
+ if not sid_text or sid_text in server_map:
+ continue
+ if (len(sid_text) > 24) or (not sid_text.isdigit()):
+ continue
+ sid = int(sid_text)
+ if sid <= 0:
+ continue
+
+ md5_hit = ""
+ try:
+ md5_hit = _lookup_resource_md5(
+ resource_conn,
+ None, # do NOT filter by chat_id: merged-forward records come from other chats
+ 0, # do NOT filter by local_type
+ int(sid),
+ 0,
+ 0,
+ )
+ except Exception:
+ md5_hit = ""
+
+ md5_hit = str(md5_hit or "").strip().lower()
+ if not _is_md5(md5_hit):
+ continue
+ if _ensure_chat_history_md5(md5_hit):
+ server_map[sid_text] = md5_hit
+ except Exception:
+ pass
+ if download_remote_media:
+ try:
+ for u in _CHAT_HISTORY_URL_TAG_RE.findall(record_item):
+ maybe_download_remote_image(u)
+ except Exception:
+ pass
+
+ lines = get_chat_history_preview_lines(msg)
+ sent_side_cls = " wechat-special-sent-side" if is_sent else ""
+ cls = f"wechat-chat-history-card wechat-special-card msg-radius{sent_side_cls} cursor-pointer"
+ tw.write(
+ f'
\n'
+ )
+ tw.write('
\n')
+ tw.write(f'
{esc_text(title)}
\n')
+ if lines:
+ tw.write('
\n')
+ for line in lines:
+ tw.write(f'
{esc_text(line)}
\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write('
合并消息
\n')
+ tw.write("
\n")
+ elif rt == "transfer":
+ received = is_transfer_received(msg)
+ returned = is_transfer_returned(msg)
+ overdue = is_transfer_overdue(msg)
+ side_cls = "wechat-transfer-sent-side" if is_sent else "wechat-transfer-received-side"
+ cls_parts = ["wechat-transfer-card", "msg-radius", side_cls]
+ if received:
+ cls_parts.append("wechat-transfer-received")
+ if returned:
+ cls_parts.append("wechat-transfer-returned")
+ if overdue:
+ cls_parts.append("wechat-transfer-overdue")
+ cls = " ".join(cls_parts)
+ if returned:
+ icon = "wechat-returned.png"
+ elif overdue:
+ icon = "overdue.png"
+ elif received:
+ icon = "wechat-trans-icon2.png"
+ else:
+ icon = "wechat-trans-icon1.png"
+ amount = format_transfer_amount(msg.get("amount"))
+ status = get_transfer_title(msg, is_sent=is_sent)
+ tw.write(f'
\n')
+ tw.write('
\n')
+ tw.write(f'
)})
\n')
+ tw.write('
\n')
+ if amount:
+ tw.write(f' ¥{esc_text(amount)}\n')
+ tw.write(f' {esc_text(status)}\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write('
微信转账
\n')
+ tw.write("
\n")
+ elif rt == "redPacket":
+ received = False
+ cls_parts = ["wechat-redpacket-card", "wechat-special-card", "msg-radius"]
+ if received:
+ cls_parts.append("wechat-redpacket-received")
+ if is_sent:
+ cls_parts.append("wechat-special-sent-side")
+ icon = "wechat-trans-icon4.png" if received else "wechat-trans-icon3.png"
+ tw.write(f'
\n')
+ tw.write('
\n')
+ tw.write(f'
)})
\n')
+ tw.write('
\n')
+ tw.write(f' {esc_text(get_red_packet_text(msg))}\n')
+ if received:
+ tw.write(' 已领取\n')
+ tw.write("
\n")
+ tw.write("
\n")
+ tw.write('
微信红包
\n')
+ tw.write("
\n")
+ elif rt == "text":
+ tw.write(f'
{render_text_with_emojis(msg.get("content") or "")}
\n')
+ else:
+ content = str(msg.get("content") or "").strip()
+ if not content:
+ content = f"[{str(msg.get('type') or 'unknown')}] 消息"
+ tw.write(f'
{render_text_with_emojis(content)}
\n')
+
+ tw.write("