improvement(chat): 完善会话置顶与消息卡片解析展示

- 后端:会话列表支持置顶识别(isTop)并按置顶优先排序

- 后端:修正群聊 XML 发送者提取,避免 refermsg 嵌套误识别

- 后端:完善转账状态后处理与视频缩略图 MD5 回填(packed_info_data)

- 后端:补充 quoteThumbUrl/linkType/linkStyle 字段链路

- 前端:新增置顶会话背景态、引用链接缩略图预览与 LinkCard cover 样式

- 测试:新增转账、置顶、引用解析与视频缩略图相关回归用例
This commit is contained in:
2977094657
2026-02-11 21:57:43 +08:00
parent 2ce479aefd
commit 548f3cf2c8
11 changed files with 1367 additions and 200 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -236,7 +236,13 @@
<template v-else> <template v-else>
<div v-for="contact in filteredContacts" :key="contact.id" <div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center" class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'" :class="contact.isTop
? (selectedContact?.id === contact.id
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
: (selectedContact?.id === contact.id
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
: 'hover:bg-[#eaeaea]')"
@click="selectContact(contact)"> @click="selectContact(contact)">
<div class="flex items-center space-x-3 w-full"> <div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 --> <!-- 联系人头像 -->
@@ -501,6 +507,7 @@
:fromAvatar="message.fromAvatar" :fromAvatar="message.fromAvatar"
:from="message.from" :from="message.from"
:isSent="message.isSent" :isSent="message.isSent"
:variant="message.linkCardVariant || 'default'"
/> />
<div v-else-if="message.renderType === 'file'" <div v-else-if="message.renderType === 'file'"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius" class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
@@ -651,7 +658,20 @@
class="hidden" class="hidden"
></audio> ></audio>
</div> </div>
<div v-else class="line-clamp-2"> <div v-else class="min-w-0 flex items-start">
<template v-if="isQuotedLink(message)">
<div class="line-clamp-2 min-w-0 flex-1">
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
<span
v-if="getQuotedLinkText(message)"
:class="message.quoteTitle ? 'ml-1' : ''"
>
🔗 {{ getQuotedLinkText(message) }}
</span>
</div>
</template>
<template v-else>
<div class="line-clamp-2 min-w-0 flex-1">
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span> <span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
<span <span
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)" v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
@@ -660,16 +680,33 @@
{{ message.quoteContent }} {{ message.quoteContent }}
</span> </span>
</div> </div>
</template>
</div>
</div> </div>
<div <div
v-if="isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError" v-if="isQuotedLink(message) && message.quoteThumbUrl && !message._quoteThumbError"
class="ml-2 my-2 flex-shrink-0 w-[45px] h-[45px] rounded overflow-hidden cursor-pointer" class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteThumbUrl)"
>
<img
:src="message.quoteThumbUrl"
alt="引用链接缩略图"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="onQuoteThumbError(message)"
/>
</div>
<div
v-if="!isQuotedLink(message) && isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteImageUrl)" @click.stop="openImagePreview(message.quoteImageUrl)"
> >
<img <img
:src="message.quoteImageUrl" :src="message.quoteImageUrl"
alt="引用图片" alt="引用图片"
class="w-full h-full object-contain" class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@error="onQuoteImageError(message)" @error="onQuoteImageError(message)"
@@ -3226,12 +3263,31 @@ const isQuotedImage = (message) => {
return false return false
} }
const isQuotedLink = (message) => {
const t = String(message?.quoteType || '').trim()
if (t === '49') return true
return /^\[链接\]\s*/.test(String(message?.quoteContent || '').trim())
}
const getQuotedLinkText = (message) => {
const raw = String(message?.quoteContent || '').trim()
if (!raw) return ''
const stripped = raw.replace(/^\[链接\]\s*/u, '').trim()
return stripped || raw
}
const onQuoteImageError = (message) => { const onQuoteImageError = (message) => {
try { try {
if (message) message._quoteImageError = true if (message) message._quoteImageError = true
} catch {} } catch {}
} }
const onQuoteThumbError = (message) => {
try {
if (message) message._quoteThumbError = true
} catch {}
}
const playQuoteVoice = (message) => { const playQuoteVoice = (message) => {
playVoice({ id: getQuoteVoiceId(message) }) playVoice({ id: getQuoteVoiceId(message) })
} }
@@ -3969,7 +4025,7 @@ const getTransferTitle = (message) => {
if (message.transferStatus) return message.transferStatus if (message.transferStatus) return message.transferStatus
switch (paySubType) { switch (paySubType) {
case '1': return '转账' case '1': return '转账'
case '3': return message.isSent ? '已收' : '已被接收' case '3': return message.isSent ? '已被接收' : '已收'
case '8': return '发起转账' case '8': return '发起转账'
case '4': return '已退还' case '4': return '已退还'
case '9': return '已被退还' case '9': return '已被退还'
@@ -4136,6 +4192,7 @@ const loadSessionsForSelectedAccount = async () => {
lastMessageTime: s.lastMessageTime || '', lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0, unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup, isGroup: !!s.isGroup,
isTop: !!s.isTop,
username: s.username username: s.username
})) }))
@@ -4209,6 +4266,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
lastMessageTime: s.lastMessageTime || '', lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0, unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup, isGroup: !!s.isGroup,
isTop: !!s.isTop,
username: s.username username: s.username
})) }))
@@ -4401,6 +4459,19 @@ const normalizeMessage = (msg) => {
].filter(Boolean) ].filter(Boolean)
return parts.length ? `${mediaBase}/api/chat/media/image?${parts.join('&')}` : '' return parts.length ? `${mediaBase}/api/chat/media/image?${parts.join('&')}` : ''
})() })()
const quoteThumbUrl = (() => {
const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : ''
if (!raw) return ''
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
if (!/^https?:\/\//i.test(raw)) return raw
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
})()
return { return {
id: msg.id, id: msg.id,
@@ -4443,17 +4514,22 @@ const normalizeMessage = (msg) => {
quoteVoiceLength: msg.quoteVoiceLength || '', quoteVoiceLength: msg.quoteVoiceLength || '',
quoteVoiceUrl, quoteVoiceUrl,
quoteImageUrl: quoteImageUrl || '', quoteImageUrl: quoteImageUrl || '',
quoteThumbUrl: quoteThumbUrl || '',
_quoteImageError: false, _quoteImageError: false,
_quoteThumbError: false,
amount: msg.amount || '', amount: msg.amount || '',
coverUrl: msg.coverUrl || '', coverUrl: msg.coverUrl || '',
fileSize: msg.fileSize || '', fileSize: msg.fileSize || '',
fileMd5: msg.fileMd5 || '', fileMd5: msg.fileMd5 || '',
paySubType: msg.paySubType || '', paySubType: msg.paySubType || '',
transferStatus: msg.transferStatus || '', transferStatus: msg.transferStatus || '',
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款', transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
voiceUrl: normalizedVoiceUrl || '', voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '', voiceDuration: msg.voiceLength || msg.voiceDuration || '',
preview: normalizedLinkPreviewUrl || '', preview: normalizedLinkPreviewUrl || '',
linkType: String(msg.linkType || '').trim(),
linkStyle: String(msg.linkStyle || '').trim(),
linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default',
from: String(msg.from || '').trim(), from: String(msg.from || '').trim(),
fromUsername, fromUsername,
fromAvatar, fromAvatar,
@@ -5331,7 +5407,8 @@ const LinkCard = defineComponent({
preview: { type: String, default: '' }, preview: { type: String, default: '' },
fromAvatar: { type: String, default: '' }, fromAvatar: { type: String, default: '' },
from: { type: String, default: '' }, from: { type: String, default: '' },
isSent: { type: Boolean, default: false } isSent: { type: Boolean, default: false },
variant: { type: String, default: 'default' }
}, },
setup(props) { setup(props) {
const getFromText = () => { const getFromText = () => {
@@ -5356,6 +5433,65 @@ const LinkCard = defineComponent({
return t ? (Array.from(t)[0] || '') : '' return t ? (Array.from(t)[0] || '') : ''
})() })()
const fromAvatarUrl = String(props.fromAvatar || '').trim() const fromAvatarUrl = String(props.fromAvatar || '').trim()
const isCoverVariant = String(props.variant || '').trim() === 'cover'
if (isCoverVariant) {
const fromRow = h('div', { class: 'wechat-link-cover-from' }, [
h('div', { class: 'wechat-link-cover-from-avatar', 'aria-hidden': 'true' }, [
fromAvatarText || '\u200B',
fromAvatarUrl ? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-cover-from-avatar-img',
referrerpolicy: 'no-referrer',
onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} }
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
])
return h(
'a',
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
class: [
'wechat-link-card-cover',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
style: {
width: '137px',
minWidth: '137px',
maxWidth: '137px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
props.preview ? h('div', { class: 'wechat-link-cover-image-wrap' }, [
h('img', {
src: props.preview,
alt: props.heading || '链接封面',
class: 'wechat-link-cover-image',
referrerpolicy: 'no-referrer'
}),
fromRow,
]) : fromRow,
h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
].filter(Boolean)
)
}
return h( return h(
'a', 'a',
{ {
@@ -5930,11 +6066,11 @@ const LinkCard = defineComponent({
/* 已领取的转账样式 */ /* 已领取的转账样式 */
.wechat-transfer-received { .wechat-transfer-received {
background: #f8e2c6; background: #FDCE9D;
} }
.wechat-transfer-received::after { .wechat-transfer-received::after {
background: #f8e2c6; background: #FDCE9D;
} }
.wechat-transfer-received .wechat-transfer-amount, .wechat-transfer-received .wechat-transfer-amount,
@@ -6258,6 +6394,111 @@ const LinkCard = defineComponent({
white-space: nowrap; white-space: nowrap;
} }
/* 链接封面卡片170x230 图 + 60 底栏) */
:deep(.wechat-link-card-cover) {
width: 137px;
min-width: 137px;
max-width: 137px;
background: #fff;
display: flex;
flex-direction: column;
box-sizing: border-box;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease;
}
:deep(.wechat-link-card-cover:hover) {
background: #f5f5f5;
}
:deep(.wechat-link-cover-image-wrap) {
width: 137px;
height: 180px;
position: relative;
overflow: hidden;
border-radius: 4px 4px 0 0;
background: #f2f2f2;
flex-shrink: 0;
}
:deep(.wechat-link-cover-image) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* 仅公众号封面卡片去掉菱形尖角,其它消息保持原样 */
:deep(.wechat-link-card-cover.wechat-special-card)::after {
content: none !important;
}
:deep(.wechat-link-cover-from) {
height: 30px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: transparent;
flex-shrink: 0;
}
:deep(.wechat-link-cover-from-avatar) {
width: 18px;
height: 18px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 11px;
line-height: 18px;
text-align: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
:deep(.wechat-link-cover-from-avatar-img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-cover-from-name) {
font-size: 12px;
color: #f3f3f3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.wechat-link-cover-title) {
height: 50px;
padding: 7px 10px 0;
box-sizing: border-box;
font-size: 12px;
line-height: 1.24;
color: #1a1a1a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
flex-shrink: 0;
}
/* 隐私模式模糊效果 */ /* 隐私模式模糊效果 */
.privacy-blur { .privacy-blur {
filter: blur(9px); filter: blur(9px);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -8,7 +8,7 @@ from collections import Counter
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import quote from urllib.parse import quote, urlparse
from fastapi import HTTPException from fastapi import HTTPException
@@ -618,6 +618,39 @@ def _normalize_xml_url(url: str) -> str:
return u.replace("&amp;", "&").strip() return u.replace("&amp;", "&").strip()
def _is_mp_weixin_article_url(url: str) -> bool:
u = str(url or "").strip()
if not u:
return False
try:
host = str(urlparse(u).hostname or "").strip().lower()
if host == "mp.weixin.qq.com" or host.endswith(".mp.weixin.qq.com"):
return True
except Exception:
pass
lu = u.lower()
return "mp.weixin.qq.com/" in lu
def _classify_link_share(*, app_type: int, url: str, source_username: str, desc: str) -> tuple[str, str]:
src = str(source_username or "").strip().lower()
is_official_article = bool(
app_type in (5, 68)
and (_is_mp_weixin_article_url(url) or src.startswith("gh_"))
)
link_type = "official_article" if is_official_article else "web_link"
d = str(desc or "").strip()
hashtag_count = len(re.findall(r"#[^#\s]+", d))
# 公众号文章中「封面图 + 底栏标题」卡片特征:摘要以 #话题# 风格为主。
link_style = "cover" if (is_official_article and (d.startswith("#") or hashtag_count >= 2)) else "default"
return link_type, link_style
def _extract_xml_tag_text(xml_text: str, tag: str) -> str: def _extract_xml_tag_text(xml_text: str, tag: str) -> str:
if not xml_text or not tag: if not xml_text or not tag:
return "" return ""
@@ -689,6 +722,65 @@ def _extract_refermsg_block(xml_text: str) -> str:
return (m.group(1) or "").strip() if m else "" return (m.group(1) or "").strip() if m else ""
def _extract_refermsg_content(refer_block: str) -> str:
if not refer_block:
return ""
cdata_match = re.search(
r"<content\b[^>]*>\s*<!\[CDATA\[(.*?)\]\]>\s*</content>",
refer_block,
flags=re.IGNORECASE | re.DOTALL,
)
if cdata_match:
return str(cdata_match.group(1) or "").strip()
return _extract_xml_tag_text(refer_block, "content")
def _summarize_nested_quote_content(raw_content: str) -> str:
candidate = str(raw_content or "").strip()
if not candidate:
return ""
lower = candidate.lower()
if "<msg" not in lower and "<appmsg" not in lower:
return candidate
for tag in ("title", "des"):
value = _extract_xml_tag_text(candidate, tag)
if value:
return value
content_value = _extract_xml_tag_text(candidate, "content")
if content_value and (not str(content_value).lstrip().startswith("<")):
return content_value
return ""
def _extract_nested_quote_thumb_url(raw_content: str) -> str:
candidate = str(raw_content or "").strip()
if not candidate:
return ""
probes = [candidate]
if candidate.startswith("wxid_"):
colon = candidate.find(":")
if 0 < colon <= 64:
rest = candidate[colon + 1 :].strip()
if rest:
probes.append(rest)
for probe in probes:
for key in ("thumburl", "cdnthumburl", "cdnthumurl", "coverurl", "cover"):
value = _normalize_xml_url(_extract_xml_tag_or_attr(probe, key))
if value:
return value
return ""
def _infer_transfer_status_text( def _infer_transfer_status_text(
is_sent: bool, is_sent: bool,
paysubtype: str, paysubtype: str,
@@ -702,7 +794,7 @@ def _infer_transfer_status_text(
rs = str(receivestatus or "").strip() rs = str(receivestatus or "").strip()
if rs == "1": if rs == "1":
return "已收款" return "被接收" if is_sent else "收款"
if rs == "2": if rs == "2":
return "已退还" return "已退还"
if rs == "3": if rs == "3":
@@ -718,7 +810,7 @@ def _infer_transfer_status_text(
if t == "8": if t == "8":
return "发起转账" return "发起转账"
if t == "3": if t == "3":
return "已收" if is_sent else "被接" return "被接" if is_sent else "已收"
if t == "1": if t == "1":
return "转账" return "转账"
@@ -770,10 +862,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str:
if not xml_text: if not xml_text:
return "" return ""
v = _extract_xml_tag_text(xml_text, "fromusername") probe_text = xml_text
try:
# Avoid picking nested quoted-message sender from <refermsg>.
probe_text = re.sub(
r"(<refermsg[^>]*>.*?</refermsg>)",
"",
xml_text,
flags=re.IGNORECASE | re.DOTALL,
)
except Exception:
probe_text = xml_text
v = _extract_xml_tag_text(probe_text, "fromusername")
if v: if v:
return v return v
v = _extract_xml_attr(xml_text, "fromusername") v = _extract_xml_attr(probe_text, "fromusername")
if v: if v:
return v return v
return "" return ""
@@ -846,6 +950,12 @@ def _parse_app_message(text: str) -> dict[str, Any]:
if app_type in (5, 68) and url: if app_type in (5, 68) and url:
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl")) thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
link_type, link_style = _classify_link_share(
app_type=app_type,
url=url,
source_username=str(source_username or "").strip(),
desc=str(des or "").strip(),
)
return { return {
"renderType": "link", "renderType": "link",
"content": des or title or "[链接]", "content": des or title or "[链接]",
@@ -854,6 +964,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"thumbUrl": thumb_url or "", "thumbUrl": thumb_url or "",
"from": str(source_display_name or "").strip(), "from": str(source_display_name or "").strip(),
"fromUsername": str(source_username or "").strip(), "fromUsername": str(source_username or "").strip(),
"linkType": link_type,
"linkStyle": link_style,
} }
if app_type in (6, 74): if app_type in (6, 74):
@@ -907,7 +1019,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
or "" or ""
) )
refer_svrid = _extract_xml_tag_or_attr(refer_block, "svrid") refer_svrid = _extract_xml_tag_or_attr(refer_block, "svrid")
refer_content = _extract_xml_tag_text(refer_block, "content") refer_content = _extract_refermsg_content(refer_block)
refer_type = _extract_xml_tag_or_attr(refer_block, "type") refer_type = _extract_xml_tag_or_attr(refer_block, "type")
rt = (reply_text or "").strip() rt = (reply_text or "").strip()
@@ -924,6 +1036,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
refer_content = rest refer_content = rest
t = str(refer_type or "").strip() t = str(refer_type or "").strip()
quote_thumb_url = ""
quote_voice_length = "" quote_voice_length = ""
if t == "3": if t == "3":
refer_content = "[图片]" refer_content = "[图片]"
@@ -944,8 +1057,29 @@ def _parse_app_message(text: str) -> dict[str, Any]:
except Exception: except Exception:
quote_voice_length = "" quote_voice_length = ""
refer_content = "[语音]" refer_content = "[语音]"
elif t == "49" and refer_content: elif t == "57":
refer_content = f"[链接] {refer_content}".strip() summarized = _summarize_nested_quote_content(str(refer_content or ""))
if summarized:
refer_content = summarized
elif str(refer_content or "").lstrip().startswith("<"):
refer_content = "[引用消息]"
elif t in {"49", "5", "68"}:
raw_link_content = str(refer_content or "").strip()
summarized = _summarize_nested_quote_content(raw_link_content)
link_text = str(summarized or raw_link_content).strip()
quote_thumb_url = _extract_nested_quote_thumb_url(raw_link_content)
if link_text.startswith("wxid_"):
colon = link_text.find(":")
if 0 < colon <= 64:
maybe_rest = link_text[colon + 1 :].strip()
if maybe_rest:
second_try = _summarize_nested_quote_content(maybe_rest)
link_text = str(second_try or maybe_rest).strip()
if not quote_thumb_url:
quote_thumb_url = _extract_nested_quote_thumb_url(maybe_rest)
refer_content = f"[链接] {link_text}".strip() if link_text else "[链接]"
return { return {
"renderType": "quote", "renderType": "quote",
@@ -954,6 +1088,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"quoteTitle": refer_displayname or "", "quoteTitle": refer_displayname or "",
"quoteContent": refer_content or "", "quoteContent": refer_content or "",
"quoteType": t, "quoteType": t,
"quoteThumbUrl": quote_thumb_url,
"quoteServerId": str(refer_svrid or "").strip(), "quoteServerId": str(refer_svrid or "").strip(),
"quoteVoiceLength": quote_voice_length, "quoteVoiceLength": quote_voice_length,
} }
@@ -1818,10 +1953,10 @@ def _row_to_search_hit(
if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')): if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username) sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username)
if is_group and sender_prefix: if is_group and sender_prefix and (not sender_username):
sender_username = sender_prefix sender_username = sender_prefix
if is_group and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')): if is_group and (not sender_username) and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text) xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender: if xml_sender:
sender_username = xml_sender sender_username = xml_sender
@@ -1838,6 +1973,9 @@ def _row_to_search_hit(
quote_username = "" quote_username = ""
quote_title = "" quote_title = ""
quote_content = "" quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
amount = "" amount = ""
pay_sub_type = "" pay_sub_type = ""
transfer_status = "" transfer_status = ""
@@ -1854,6 +1992,9 @@ def _row_to_search_hit(
url = str(parsed.get("url") or "") url = str(parsed.get("url") or "")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
amount = str(parsed.get("amount") or "") amount = str(parsed.get("amount") or "")
pay_sub_type = str(parsed.get("paySubType") or "") pay_sub_type = str(parsed.get("paySubType") or "")
@@ -1878,6 +2019,7 @@ def _row_to_search_hit(
content_text = str(parsed.get("content") or "[引用消息]") content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
elif local_type == 3: elif local_type == 3:
render_type = "image" render_type = "image"
@@ -1927,6 +2069,9 @@ def _row_to_search_hit(
url = str(parsed.get("url") or url) url = str(parsed.get("url") or url)
quote_title = str(parsed.get("quoteTitle") or quote_title) quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content) quote_content = str(parsed.get("quoteContent") or quote_content)
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
link_type = str(parsed.get("linkType") or link_type)
link_style = str(parsed.get("linkStyle") or link_style)
amount = str(parsed.get("amount") or amount) amount = str(parsed.get("amount") or amount)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type) pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
quote_username = str(parsed.get("quoteUsername") or quote_username) quote_username = str(parsed.get("quoteUsername") or quote_username)
@@ -1966,9 +2111,12 @@ def _row_to_search_hit(
"content": content_text, "content": content_text,
"title": title, "title": title,
"url": url, "url": url,
"linkType": link_type,
"linkStyle": link_style,
"quoteUsername": quote_username, "quoteUsername": quote_username,
"quoteTitle": quote_title, "quoteTitle": quote_title,
"quoteContent": quote_content, "quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount, "amount": amount,
"paySubType": pay_sub_type, "paySubType": pay_sub_type,
"transferStatus": transfer_status, "transferStatus": transfer_status,

View File

@@ -92,6 +92,11 @@ _REALTIME_SYNC_LOCKS: dict[tuple[str, str], threading.Lock] = {}
_REALTIME_SYNC_ALL_LOCKS: dict[str, threading.Lock] = {} _REALTIME_SYNC_ALL_LOCKS: dict[str, threading.Lock] = {}
def _is_hex_md5(value: Any) -> bool:
s = str(value or "").strip().lower()
return len(s) == 32 and all(c in "0123456789abcdef" for c in s)
def _avatar_url_unified( def _avatar_url_unified(
*, *,
account_dir: Path, account_dir: Path,
@@ -787,6 +792,82 @@ def _load_session_last_message_times(conn: sqlite3.Connection, usernames: list[s
return out return out
def _session_row_get(row: Any, key: str, default: Any = None) -> Any:
try:
if isinstance(row, sqlite3.Row):
return row[key]
except Exception:
return default
try:
return row.get(key, default)
except Exception:
return default
def _contact_flag_is_top(flag_value: Any) -> bool:
try:
flag_int = int(flag_value)
except Exception:
return False
if flag_int < 0:
flag_int &= (1 << 64) - 1
return bool((flag_int >> 11) & 1)
def _load_contact_top_flags(contact_db_path: Path, usernames: list[str]) -> dict[str, bool]:
uniq = list(dict.fromkeys([str(u or "").strip() for u in usernames if str(u or "").strip()]))
if not uniq:
return {}
if not contact_db_path.exists():
return {}
out: dict[str, bool] = {}
conn = sqlite3.connect(str(contact_db_path))
conn.row_factory = sqlite3.Row
try:
def has_flag_column(table: str) -> bool:
try:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
except Exception:
return False
cols: set[str] = set()
for r in rows:
try:
cols.add(str(r["name"] if isinstance(r, sqlite3.Row) else r[1]).strip().lower())
except Exception:
continue
return ("username" in cols) and ("flag" in cols)
chunk_size = 900
for table in ("contact", "stranger"):
if not has_flag_column(table):
continue
for i in range(0, len(uniq), chunk_size):
chunk = uniq[i : i + chunk_size]
placeholders = ",".join(["?"] * len(chunk))
try:
rows = conn.execute(
f"SELECT username, flag FROM {table} WHERE username IN ({placeholders})",
chunk,
).fetchall()
except Exception:
continue
for r in rows:
username = str(_session_row_get(r, "username", "") or "").strip()
if not username:
continue
is_top = _contact_flag_is_top(_session_row_get(r, "flag", 0))
if is_top:
out[username] = True
else:
out.setdefault(username, False)
return out
finally:
conn.close()
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)") @router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
def sync_chat_realtime_messages( def sync_chat_realtime_messages(
request: Request, request: Request,
@@ -2251,7 +2332,7 @@ def _append_full_messages_from_rows(
if is_group and sender_prefix and (not sender_username): if is_group and sender_prefix and (not sender_username):
sender_username = sender_prefix sender_username = sender_prefix
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text) xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender: if xml_sender:
sender_username = xml_sender sender_username = xml_sender
@@ -2287,6 +2368,9 @@ def _append_full_messages_from_rows(
quote_username = "" quote_username = ""
quote_title = "" quote_title = ""
quote_content = "" quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = "" quote_server_id = ""
quote_type = "" quote_type = ""
quote_voice_length = "" quote_voice_length = ""
@@ -2313,6 +2397,9 @@ def _append_full_messages_from_rows(
record_item = str(parsed.get("recordItem") or "") record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -2356,6 +2443,9 @@ def _append_full_messages_from_rows(
content_text = str(parsed.get("content") or "[引用消息]") content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -2468,6 +2558,20 @@ def _append_full_messages_from_rows(
local_id=local_id, local_id=local_id,
create_time=create_time, create_time=create_time,
) )
# Some WeChat builds store the on-disk thumbnail basename (32-hex) in packed_info_data (protobuf),
# while the message XML only carries a long cdnthumburl file_id. Prefer packed_info_data when present.
if not _is_hex_md5(video_thumb_md5):
try:
packed_val = r["packed_info_data"]
except Exception:
try:
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
except Exception:
packed_val = None
packed_md5 = _extract_md5_from_packed_info(packed_val)
if packed_md5:
video_thumb_md5 = packed_md5
content_text = "[视频]" content_text = "[视频]"
elif local_type == 47: elif local_type == 47:
render_type = "emoji" render_type = "emoji"
@@ -2527,6 +2631,9 @@ def _append_full_messages_from_rows(
record_item = str(parsed.get("recordItem") or record_item) record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title) quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content) quote_content = str(parsed.get("quoteContent") or quote_content)
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
link_type = str(parsed.get("linkType") or link_type)
link_style = str(parsed.get("linkStyle") or link_style)
amount = str(parsed.get("amount") or amount) amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url) cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -2578,6 +2685,8 @@ def _append_full_messages_from_rows(
"content": content_text, "content": content_text,
"title": title, "title": title,
"url": url, "url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name, "from": from_name,
"fromUsername": from_username, "fromUsername": from_username,
"recordItem": record_item, "recordItem": record_item,
@@ -2601,6 +2710,7 @@ def _append_full_messages_from_rows(
"quoteVoiceLength": str(quote_voice_length).strip(), "quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title, "quoteTitle": quote_title,
"quoteContent": quote_content, "quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount, "amount": amount,
"coverUrl": cover_url, "coverUrl": cover_url,
"fileSize": file_size, "fileSize": file_size,
@@ -2619,35 +2729,37 @@ def _append_full_messages_from_rows(
pass pass
def _postprocess_full_messages( def _postprocess_transfer_messages(merged: list[dict[str, Any]]) -> None:
*,
merged: list[dict[str, Any]],
sender_usernames: list[str],
quote_usernames: list[str],
pat_usernames: set[str],
account_dir: Path,
username: str,
base_url: str,
contact_db_path: Path,
head_image_db_path: Path,
) -> None:
# 后处理:关联转账消息的最终状态 # 后处理:关联转账消息的最终状态
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配 # 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期 # paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
#
# Windows 微信在部分场景会为同一笔转账记录两条消息:
# - paysubtype=1/8发起/待收款(这里回填为“已被接收”)
# - paysubtype=3收款确认展示为“已收款”
#
# 这两条消息的 isSent 并不能稳定表示“付款方/收款方视角”,因此这里以 transferId 关联结果为准:
# - 将原始转账消息1/8回填为“已被接收”
# - 若同一 transferId 同时存在原始消息与 paysubtype=3 消息,则将 paysubtype=3 的那条校正为“已收款”
# 收集已退还和已收款的转账ID和金额
returned_transfer_ids: set[str] = set() # 退还状态的 transferId returned_transfer_ids: set[str] = set() # 退还状态的 transferId
received_transfer_ids: set[str] = set() # 已收款状态的 transferId received_transfer_ids: set[str] = set() # 已收款状态的 transferId
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配 returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配 received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
pending_transfer_ids: set[str] = set() # (paysubtype=1/8) 的 transferId用于识别“收款确认”消息
for m in merged: for m in merged:
if m.get("renderType") == "transfer": if m.get("renderType") != "transfer":
continue
pst = str(m.get("paySubType") or "") pst = str(m.get("paySubType") or "")
tid = str(m.get("transferId") or "").strip() tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "") amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0) ts = int(m.get("createTime") or 0)
if tid and pst in ("1", "8"):
pending_transfer_ids.add(tid)
if pst in ("4", "9"): # 退还状态 if pst in ("4", "9"): # 退还状态
if tid: if tid:
returned_transfer_ids.add(tid) returned_transfer_ids.add(tid)
@@ -2659,17 +2771,20 @@ def _postprocess_full_messages(
if amt: if amt:
received_amounts_with_time.append((amt, ts)) received_amounts_with_time.append((amt, ts))
# 更新原始转账消息的状态 backfilled_message_ids: set[str] = set()
for m in merged: for m in merged:
if m.get("renderType") == "transfer": if m.get("renderType") != "transfer":
continue
pst = str(m.get("paySubType") or "") pst = str(m.get("paySubType") or "")
# 只更新未确定状态的原始转账消息paysubtype=1 或 8 if pst not in ("1", "8"):
if pst in ("1", "8"): continue
tid = str(m.get("transferId") or "").strip() tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "") amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0) ts = int(m.get("createTime") or 0)
# 优先检查退还状态(退还优先于收款)
should_mark_returned = False should_mark_returned = False
should_mark_received = False should_mark_received = False
@@ -2697,9 +2812,41 @@ def _postprocess_full_messages(
m["transferStatus"] = "已被退还" m["transferStatus"] = "已被退还"
elif should_mark_received: elif should_mark_received:
m["paySubType"] = "3" m["paySubType"] = "3"
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收" m["transferStatus"] = "已被接收"
is_sent = m.get("isSent", False) mid = str(m.get("id") or "").strip()
m["transferStatus"] = "已收款" if is_sent else "已被接收" if mid:
backfilled_message_ids.add(mid)
# 修正收款确认消息:当同一 transferId 同时存在原始转账消息1/8与收款消息3
# paysubtype=3 的那条通常是收款确认消息,状态文案应为“已收款”。
for m in merged:
if m.get("renderType") != "transfer":
continue
pst = str(m.get("paySubType") or "")
if pst != "3":
continue
tid = str(m.get("transferId") or "").strip()
if not tid or tid not in pending_transfer_ids:
continue
mid = str(m.get("id") or "").strip()
if mid and mid in backfilled_message_ids:
continue
m["transferStatus"] = "已收款"
def _postprocess_full_messages(
*,
merged: list[dict[str, Any]],
sender_usernames: list[str],
quote_usernames: list[str],
pat_usernames: set[str],
account_dir: Path,
username: str,
base_url: str,
contact_db_path: Path,
head_image_db_path: Path,
) -> None:
_postprocess_transfer_messages(merged)
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername). # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar. # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
@@ -3074,20 +3221,45 @@ def list_chat_sessions(
finally: finally:
sconn.close() sconn.close()
filtered: list[sqlite3.Row] = [] filtered: list[Any] = []
usernames: list[str] = []
for r in rows: for r in rows:
username = r["username"] or "" username = _session_row_get(r, "username", "") or ""
if not username: if not username:
continue continue
if not include_hidden and int(r["is_hidden"] or 0) == 1: if not include_hidden and int((_session_row_get(r, "is_hidden", 0) or 0)) == 1:
continue continue
if not _should_keep_session(username, include_official=include_official): if not _should_keep_session(username, include_official=include_official):
continue continue
filtered.append(r) filtered.append(r)
raw_usernames = [str(_session_row_get(r, "username", "") or "").strip() for r in filtered]
top_flags = _load_contact_top_flags(contact_db_path, raw_usernames)
def _to_int(v: Any) -> int:
try:
return int(v or 0)
except Exception:
return 0
def _session_sort_key(row: Any) -> tuple[int, int, int]:
username = str(_session_row_get(row, "username", "") or "").strip()
sort_ts = _to_int(_session_row_get(row, "sort_timestamp", 0))
last_ts = _to_int(_session_row_get(row, "last_timestamp", 0))
return (
1 if bool(top_flags.get(username, False)) else 0,
sort_ts,
last_ts,
)
filtered.sort(key=_session_sort_key, reverse=True)
if len(filtered) > int(limit):
filtered = filtered[: int(limit)]
usernames: list[str] = []
for r in filtered:
username = str(_session_row_get(r, "username", "") or "").strip()
if username:
usernames.append(username) usernames.append(username)
if len(filtered) >= int(limit):
break
contact_rows = _load_contact_rows(contact_db_path, usernames) contact_rows = _load_contact_rows(contact_db_path, usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames) local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
@@ -3121,7 +3293,15 @@ def list_chat_sessions(
need_display = list(dict.fromkeys(need_display)) need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar)) need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar: if need_display or need_avatar:
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir) wcdb_conn = rt_conn
if wcdb_conn is None:
status = WCDB_REALTIME.get_status(account_dir)
can_connect = bool(status.get("dll_present")) and bool(status.get("key_present")) and bool(
status.get("session_db_path")
)
if can_connect:
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
if wcdb_conn is not None:
with wcdb_conn.lock: with wcdb_conn.lock:
if need_display: if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display) wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
@@ -3296,6 +3476,7 @@ def list_chat_sessions(
"lastMessageTime": last_time, "lastMessageTime": last_time,
"unreadCount": int(r["unread_count"] or 0), "unreadCount": int(r["unread_count"] or 0),
"isGroup": bool(username.endswith("@chatroom")), "isGroup": bool(username.endswith("@chatroom")),
"isTop": bool(top_flags.get(str(username or "").strip(), False)),
} }
) )
@@ -3439,7 +3620,7 @@ def _collect_chat_messages(
if is_group and sender_prefix and (not sender_username): if is_group and sender_prefix and (not sender_username):
sender_username = sender_prefix sender_username = sender_prefix
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text) xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender: if xml_sender:
sender_username = xml_sender sender_username = xml_sender
@@ -3472,6 +3653,9 @@ def _collect_chat_messages(
quote_username = "" quote_username = ""
quote_title = "" quote_title = ""
quote_content = "" quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = "" quote_server_id = ""
quote_type = "" quote_type = ""
quote_voice_length = "" quote_voice_length = ""
@@ -3498,6 +3682,9 @@ def _collect_chat_messages(
record_item = str(parsed.get("recordItem") or "") record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -3541,6 +3728,9 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or "[引用消息]") content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -3640,6 +3830,11 @@ def _collect_chat_messages(
local_id=local_id, local_id=local_id,
create_time=create_time, create_time=create_time,
) )
if not _is_hex_md5(video_thumb_md5):
packed_md5 = _extract_md5_from_packed_info(r["packed_info_data"])
if packed_md5:
video_thumb_md5 = packed_md5
content_text = "[视频]" content_text = "[视频]"
elif local_type == 47: elif local_type == 47:
render_type = "emoji" render_type = "emoji"
@@ -3701,6 +3896,9 @@ def _collect_chat_messages(
record_item = str(parsed.get("recordItem") or record_item) record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title) quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content) quote_content = str(parsed.get("quoteContent") or quote_content)
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
link_type = str(parsed.get("linkType") or link_type)
link_style = str(parsed.get("linkStyle") or link_style)
amount = str(parsed.get("amount") or amount) amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url) cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -3758,6 +3956,8 @@ def _collect_chat_messages(
"content": content_text, "content": content_text,
"title": title, "title": title,
"url": url, "url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name, "from": from_name,
"fromUsername": from_username, "fromUsername": from_username,
"recordItem": record_item, "recordItem": record_item,
@@ -3781,6 +3981,7 @@ def _collect_chat_messages(
"quoteVoiceLength": str(quote_voice_length).strip(), "quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title, "quoteTitle": quote_title,
"quoteContent": quote_content, "quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount, "amount": amount,
"coverUrl": cover_url, "coverUrl": cover_url,
"fileSize": file_size, "fileSize": file_size,
@@ -4139,7 +4340,7 @@ def list_chat_messages(
if is_group and sender_prefix: if is_group and sender_prefix:
sender_username = sender_prefix sender_username = sender_prefix
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text) xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender: if xml_sender:
sender_username = xml_sender sender_username = xml_sender
@@ -4175,6 +4376,9 @@ def list_chat_messages(
quote_username = "" quote_username = ""
quote_title = "" quote_title = ""
quote_content = "" quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = "" quote_server_id = ""
quote_type = "" quote_type = ""
quote_voice_length = "" quote_voice_length = ""
@@ -4201,6 +4405,9 @@ def list_chat_messages(
record_item = str(parsed.get("recordItem") or "") record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -4244,6 +4451,9 @@ def list_chat_messages(
content_text = str(parsed.get("content") or "[引用消息]") content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "") quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "") quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
quote_username = str(parsed.get("quoteUsername") or "") quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "") quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "") quote_type = str(parsed.get("quoteType") or "")
@@ -4400,6 +4610,9 @@ def list_chat_messages(
record_item = str(parsed.get("recordItem") or record_item) record_item = str(parsed.get("recordItem") or record_item)
quote_title = str(parsed.get("quoteTitle") or quote_title) quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content) quote_content = str(parsed.get("quoteContent") or quote_content)
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
link_type = str(parsed.get("linkType") or link_type)
link_style = str(parsed.get("linkStyle") or link_style)
amount = str(parsed.get("amount") or amount) amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url) cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -4450,6 +4663,8 @@ def list_chat_messages(
"content": content_text, "content": content_text,
"title": title, "title": title,
"url": url, "url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name, "from": from_name,
"fromUsername": from_username, "fromUsername": from_username,
"recordItem": record_item, "recordItem": record_item,
@@ -4473,6 +4688,7 @@ def list_chat_messages(
"quoteVoiceLength": str(quote_voice_length).strip(), "quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title, "quoteTitle": quote_title,
"quoteContent": quote_content, "quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount, "amount": amount,
"coverUrl": cover_url, "coverUrl": cover_url,
"fileSize": file_size, "fileSize": file_size,
@@ -4509,81 +4725,38 @@ def list_chat_messages(
deduped.append(m) deduped.append(m)
merged = deduped merged = deduped
# 后处理:关联转账消息的最终状态 _postprocess_transfer_messages(merged)
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
# 收集已退还和已收款的转账ID和金额 def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
returned_transfer_ids: set[str] = set() # 退还状态的 transferId sseq = int(m.get("sortSeq") or 0)
received_transfer_ids: set[str] = set() # 已收款状态的 transferId cts = int(m.get("createTime") or 0)
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配 lid = int(m.get("localId") or 0)
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配 return (cts, sseq, lid)
for m in merged: merged.sort(key=sort_key, reverse=True)
if m.get("renderType") == "transfer": has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit))))
pst = str(m.get("paySubType") or "") page = merged[int(offset) : int(offset) + int(limit)]
tid = str(m.get("transferId") or "").strip() if want_asc:
amt = str(m.get("amount") or "") page = list(reversed(page))
ts = int(m.get("createTime") or 0)
if pst in ("4", "9"): # 退还状态 # Hot path optimization: only enrich the page we return.
if tid: if not page:
returned_transfer_ids.add(tid) return {
if amt: "status": "success",
returned_amounts_with_time.append((amt, ts)) "account": account_dir.name,
elif pst == "3": # 已收款状态 "username": username,
if tid: "total": int(offset) + (1 if has_more_global else 0),
received_transfer_ids.add(tid) "hasMore": bool(has_more_global),
if amt: "messages": [],
received_amounts_with_time.append((amt, ts)) }
# 更新原始转账消息的状态 messages_window = page
for m in merged:
if m.get("renderType") == "transfer":
pst = str(m.get("paySubType") or "")
# 只更新未确定状态的原始转账消息paysubtype=1 或 8
if pst in ("1", "8"):
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0)
# 优先检查退还状态(退还优先于收款)
should_mark_returned = False
should_mark_received = False
# 策略1精确 transferId 匹配
if tid:
if tid in returned_transfer_ids:
should_mark_returned = True
elif tid in received_transfer_ids:
should_mark_received = True
# 策略2回退到金额+时间窗口匹配24小时内同金额
if not should_mark_returned and not should_mark_received and amt:
for ret_amt, ret_ts in returned_amounts_with_time:
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
should_mark_returned = True
break
if not should_mark_returned:
for rec_amt, rec_ts in received_amounts_with_time:
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
should_mark_received = True
break
if should_mark_returned:
m["paySubType"] = "9"
m["transferStatus"] = "已被退还"
elif should_mark_received:
m["paySubType"] = "3"
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收"
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername). # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar. # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
missing_from_names = [ missing_from_names = [
str(m.get("from") or "").strip() str(m.get("from") or "").strip()
for m in merged for m in messages_window
if str(m.get("renderType") or "").strip() == "link" if str(m.get("renderType") or "").strip() == "link"
and str(m.get("from") or "").strip() and str(m.get("from") or "").strip()
and not str(m.get("fromUsername") or "").strip() and not str(m.get("fromUsername") or "").strip()
@@ -4591,7 +4764,7 @@ def list_chat_messages(
if missing_from_names: if missing_from_names:
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names) name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
if name_to_username: if name_to_username:
for m in merged: for m in messages_window:
if str(m.get("fromUsername") or "").strip(): if str(m.get("fromUsername") or "").strip():
continue continue
if str(m.get("renderType") or "").strip() != "link": if str(m.get("renderType") or "").strip() != "link":
@@ -4600,10 +4773,33 @@ def list_chat_messages(
if fn and fn in name_to_username: if fn and fn in name_to_username:
m["fromUsername"] = name_to_username[fn] m["fromUsername"] = name_to_username[fn]
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged] pat_usernames_in_page: set[str] = set()
for m in messages_window:
if int(m.get("type") or 0) != 266287972401:
continue
raw = str(m.get("_rawText") or "")
if not raw:
continue
template = _extract_xml_tag_text(raw, "template")
if not template:
continue
pat_usernames_in_page.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)})
from_usernames = [str(m.get("fromUsername") or "").strip() for m in messages_window]
sender_usernames_in_page = [str(m.get("senderUsername") or "").strip() for m in messages_window]
quote_usernames_in_page = [str(m.get("quoteUsername") or "").strip() for m in messages_window]
uniq_senders = list( uniq_senders = list(
dict.fromkeys( dict.fromkeys(
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u] [
u
for u in (
sender_usernames_in_page
+ list(pat_usernames_in_page)
+ quote_usernames_in_page
+ from_usernames
)
if u
]
) )
) )
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders) sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
@@ -4645,7 +4841,7 @@ def list_chat_messages(
sender_usernames=uniq_senders, sender_usernames=uniq_senders,
) )
for m in merged: for m in messages_window:
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name. # If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip(): if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
fu = str(m.get("fromUsername") or "").strip() fu = str(m.get("fromUsername") or "").strip()
@@ -4789,18 +4985,6 @@ def list_chat_messages(
if "_rawText" in m: if "_rawText" in m:
m.pop("_rawText", None) m.pop("_rawText", None)
def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
sseq = int(m.get("sortSeq") or 0)
cts = int(m.get("createTime") or 0)
lid = int(m.get("localId") or 0)
return (cts, sseq, lid)
merged.sort(key=sort_key, reverse=True)
has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit))))
page = merged[int(offset) : int(offset) + int(limit)]
if want_asc:
page = list(reversed(page))
return { return {
"status": "success", "status": "success",
"account": account_dir.name, "account": account_dir.name,
@@ -5762,10 +5946,21 @@ async def get_chat_messages_around(
my_rowid = None my_rowid = None
quoted_table = _quote_ident(table_name) quoted_table = _quote_ident(table_name)
has_packed_info_data = False
try:
cols = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall()
has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols)
except Exception:
has_packed_info_data = False
packed_select = (
"m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, "
)
sql_anchor_with_join = ( sql_anchor_with_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, n.user_name AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "n.user_name AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"WHERE m.local_id = ? " "WHERE m.local_id = ? "
@@ -5774,7 +5969,9 @@ async def get_chat_messages_around(
sql_anchor_no_join = ( sql_anchor_no_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, '' AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "'' AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
"WHERE m.local_id = ? " "WHERE m.local_id = ? "
"LIMIT 1" "LIMIT 1"
@@ -5811,7 +6008,9 @@ async def get_chat_messages_around(
sql_before_with_join = ( sql_before_with_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, n.user_name AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "n.user_name AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
f"{where_before} " f"{where_before} "
@@ -5821,7 +6020,9 @@ async def get_chat_messages_around(
sql_before_no_join = ( sql_before_no_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, '' AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "'' AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
f"{where_before} " f"{where_before} "
"ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC "
@@ -5831,7 +6032,9 @@ async def get_chat_messages_around(
sql_after_with_join = ( sql_after_with_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, n.user_name AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "n.user_name AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
f"{where_after} " f"{where_after} "
@@ -5841,7 +6044,9 @@ async def get_chat_messages_around(
sql_after_no_join = ( sql_after_no_join = (
"SELECT " "SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, '' AS sender_username " "m.message_content, m.compress_content, "
+ packed_select
+ "'' AS sender_username "
f"FROM {quoted_table} m " f"FROM {quoted_table} m "
f"{where_after} " f"{where_after} "
"ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC "

View File

@@ -0,0 +1,93 @@
import sys
import threading
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
class _DummyRequest:
base_url = "http://testserver/"
class _DummyConn:
def __init__(self) -> None:
self.handle = 1
self.lock = threading.Lock()
class TestChatRealtimeVideoThumbMd5FromPackedInfo(unittest.TestCase):
def test_video_thumb_md5_filled_from_packed_info(self):
packed_md5 = "faff984641f9dd174e01c74f0796c9ae"
file_id = "3057020100044b3049020100020445eb9d5102032f54690204749999db0204698c336b0424deadbeef"
video_md5 = "22e6612411898b6d43b7e773e504d506"
xml = (
'<?xml version="1.0"?>\n'
"<msg>\n"
f' <videomsg fromusername="wxid_sender" md5="{video_md5}" cdnthumburl="{file_id}" cdnvideourl="{file_id}" />\n'
"</msg>\n"
)
wcdb_rows = [
{
"localId": 1,
"serverId": 123,
"localType": 43,
"sortSeq": 1700000000000,
"realSenderId": 1,
"createTime": 1700000000,
"messageContent": xml,
"compressContent": None,
"packedInfoData": packed_md5.encode("ascii"),
"senderUsername": "wxid_sender",
}
]
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
conn = _DummyConn()
with (
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
patch.object(chat_router, "_load_contact_rows", return_value={}),
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
):
resp = chat_router.list_chat_messages(
_DummyRequest(),
username="demo@chatroom",
account="acc",
limit=50,
offset=0,
order="asc",
render_types=None,
source="realtime",
)
self.assertEqual(resp.get("status"), "success")
messages = resp.get("messages") or []
self.assertEqual(len(messages), 1)
msg = messages[0]
self.assertEqual(msg.get("renderType"), "video")
self.assertEqual(msg.get("videoThumbMd5"), packed_md5)
thumb_url = str(msg.get("videoThumbUrl") or "")
self.assertIn(f"md5={packed_md5}", thumb_url)
self.assertNotIn("file_id=", thumb_url)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,211 @@
import sqlite3
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
class _DummyRequest:
base_url = "http://testserver/"
def _seed_session_db(path: Path, rows: list[tuple[str, int, int, str]]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable(
username TEXT PRIMARY KEY,
unread_count INTEGER,
is_hidden INTEGER,
summary TEXT,
draft TEXT,
last_timestamp INTEGER,
sort_timestamp INTEGER,
last_msg_type INTEGER,
last_msg_sub_type INTEGER
)
"""
)
for username, sort_timestamp, last_timestamp, summary in rows:
conn.execute(
"""
INSERT INTO SessionTable(
username, unread_count, is_hidden, summary, draft,
last_timestamp, sort_timestamp, last_msg_type, last_msg_sub_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
username,
0,
0,
summary,
"",
int(last_timestamp),
int(sort_timestamp),
1,
0,
),
)
conn.commit()
finally:
conn.close()
def _seed_contact_db_with_flag(path: Path, flags: dict[str, int]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact(
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT,
flag INTEGER
)
"""
)
conn.execute(
"""
CREATE TABLE stranger(
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT,
flag INTEGER
)
"""
)
for username, flag in flags.items():
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?)",
(username, "", "", "", "", "", int(flag)),
)
conn.commit()
finally:
conn.close()
def _seed_contact_db_without_flag(path: Path, usernames: list[str]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact(
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger(
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
for username in usernames:
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
(username, "", "", "", "", ""),
)
conn.commit()
finally:
conn.close()
class TestChatSessionsPinning(unittest.TestCase):
def test_pinned_session_is_sorted_first_and_has_is_top(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
_seed_session_db(
account_dir / "session.db",
[
("wxid_new", 200, 200, "new message"),
("wxid_top", 100, 100, "top older message"),
],
)
_seed_contact_db_with_flag(
account_dir / "contact.db",
{
"wxid_new": 0,
"wxid_top": 1 << 11,
},
)
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.list_chat_sessions(
_DummyRequest(),
account="acc",
limit=50,
include_hidden=True,
include_official=True,
preview="session",
source="",
)
self.assertEqual(resp.get("status"), "success")
sessions = resp.get("sessions") or []
self.assertEqual(len(sessions), 2)
self.assertEqual(sessions[0].get("username"), "wxid_top")
self.assertTrue(bool(sessions[0].get("isTop")))
self.assertEqual(sessions[1].get("username"), "wxid_new")
self.assertFalse(bool(sessions[1].get("isTop")))
def test_missing_flag_column_does_not_error_and_defaults_false(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
_seed_session_db(
account_dir / "session.db",
[
("wxid_top", 100, 100, "hello"),
],
)
_seed_contact_db_without_flag(account_dir / "contact.db", ["wxid_top"])
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.list_chat_sessions(
_DummyRequest(),
account="acc",
limit=50,
include_hidden=True,
include_official=True,
preview="session",
source="",
)
self.assertEqual(resp.get("status"), "success")
sessions = resp.get("sessions") or []
self.assertEqual(len(sessions), 1)
self.assertFalse(bool(sessions[0].get("isTop")))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,23 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.chat_helpers import _extract_sender_from_group_xml
class TestGroupXmlSenderExtraction(unittest.TestCase):
def test_prefers_outer_fromusername_over_nested_refermsg(self):
xml_text = (
'<msg><appmsg><type>57</type>'
'<refermsg><fromusername>quoted_user@chatroom</fromusername></refermsg>'
'</appmsg><fromusername>actual_sender@chatroom</fromusername></msg>'
)
self.assertEqual(_extract_sender_from_group_xml(xml_text), "actual_sender@chatroom")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,115 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.chat_helpers import _parse_app_message
class TestParseAppMessage(unittest.TestCase):
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>一松一紧</title><des></des><action></action><type>57</type>'
'<showtype>0</showtype><soundtype>0</soundtype><mediatagname></mediatagname>'
'<messageext></messageext><messageaction></messageaction><content></content>'
'<url></url><appattach><totallen>0</totallen><attachid></attachid><fileext></fileext></appattach>'
'<extinfo></extinfo><sourceusername></sourceusername><sourcedisplayname></sourcedisplayname>'
'<commenturl></commenturl><refermsg>'
'<type>57</type><svrid>1173057991425172913</svrid>'
'<fromusr>44372432598@chatroom</fromusr><chatusr>44372432598@chatroom</chatusr>'
'<displayname><![CDATA[ㅤ磁父]]></displayname>'
'<content><![CDATA[<msg><appmsg appid="" sdkver="0"><title>那里紧?哪里张?</title><des></des>'
'<action></action><type>57</type><showtype>0</showtype><soundtype>0</soundtype>'
'<mediatagname></mediatagname><messageext></messageext><messageaction></messageaction>'
'<content></content><url></url><appattach><totallen>0</totallen><attachid></attachid>'
'<fileext></fileext></appattach><extinfo></extinfo><sourceusername></sourceusername>'
'<sourcedisplayname></sourcedisplayname><commenturl></commenturl></appmsg></msg>]]></content>'
'</refermsg></appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "quote")
self.assertEqual(parsed.get("content"), "一松一紧")
self.assertEqual(parsed.get("quoteType"), "57")
self.assertEqual(parsed.get("quoteContent"), "那里紧?哪里张?")
def test_quote_type_57_plain_text_refermsg_keeps_text(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>回复</title><type>57</type>'
'<refermsg><type>57</type><content><![CDATA[普通文本引用]]></content></refermsg>'
'</appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "quote")
self.assertEqual(parsed.get("quoteContent"), "普通文本引用")
def test_quote_type_49_nested_xml_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>这种傻逼公众号怎么还在看</title><type>57</type>'
'<refermsg><type>49</type><displayname><![CDATA[水豚喧喧]]></displayname>'
'<content><![CDATA[wxid_gryaI8aopjio22: <?xml version="1.0"?><msg><appmsg appid="" sdkver="0">'
'<title>为自己的美丽漂亮善良知性发声😊</title><des></des>'
'<type>5</type><url>https://mp.weixin.qq.com/s/example</url>'
'<thumburl>https://mmbiz.qpic.cn/some-thumb.jpg</thumburl>'
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "quote")
self.assertEqual(parsed.get("quoteType"), "49")
self.assertEqual(parsed.get("quoteTitle"), "水豚喧喧")
self.assertEqual(parsed.get("quoteContent"), "[链接] 为自己的美丽漂亮善良知性发声😊")
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb.jpg")
def test_public_account_link_exposes_link_type_and_style(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>为自己的美丽漂亮善良知性发声😊</title>'
'<des>#日常穿搭灵感 #白色蕾丝裙穿搭 #知性美女</des>'
'<type>5</type>'
'<url>http://mp.weixin.qq.com/s?__biz=xx&mid=1</url>'
'<thumburl>http://mmbiz.qpic.cn/abc/640?wx_fmt=jpeg</thumburl>'
'<sourceusername>gh_0cef8eaa987d</sourceusername>'
'<sourcedisplayname>草莓不甜芒果甜</sourcedisplayname>'
'</appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "cover")
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>这个年龄有点大啊</title><type>57</type>'
'<refermsg><type>5</type><displayname><![CDATA[水豚噜噜]]></displayname>'
'<content><![CDATA[wxid_qrval8aopiio22:\n<?xml version="1.0"?>\n<msg><appmsg appid="" sdkver="0">'
'<title>谁说冬天不能穿裙子?</title><des></des><type>5</type>'
'<thumburl>https://mmbiz.qpic.cn/some-thumb2.jpg</thumburl>'
'<url>https://mp.weixin.qq.com/s/example2</url>'
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "quote")
self.assertEqual(parsed.get("quoteType"), "5")
self.assertEqual(parsed.get("quoteTitle"), "水豚噜噜")
self.assertEqual(parsed.get("quoteContent"), "[链接] 谁说冬天不能穿裙子?")
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb2.jpg")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,68 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
class TestTransferPostprocess(unittest.TestCase):
def test_backfilled_pending_and_received_confirmation_have_expected_titles(self):
transfer_id = "1000050001202601152035503031545"
merged = [
{
"id": "message_0:Msg_x:60",
"renderType": "transfer",
"paySubType": "1",
"transferId": transfer_id,
"amount": "¥100.00",
"createTime": 1768463200,
"isSent": False,
"transferStatus": "",
},
{
"id": "message_0:Msg_x:65",
"renderType": "transfer",
"paySubType": "3",
"transferId": transfer_id,
"amount": "¥100.00",
"createTime": 1768463246,
"isSent": True,
# Pre-inferred value (may be "已被接收") should be corrected by postprocess.
"transferStatus": "已被接收",
},
]
chat_router._postprocess_transfer_messages(merged)
self.assertEqual(merged[0].get("paySubType"), "3")
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
self.assertEqual(merged[1].get("paySubType"), "3")
self.assertEqual(merged[1].get("transferStatus"), "已收款")
def test_received_message_without_pending_is_left_unchanged(self):
merged = [
{
"id": "message_0:Msg_x:65",
"renderType": "transfer",
"paySubType": "3",
"transferId": "t1",
"amount": "¥100.00",
"createTime": 1,
"isSent": True,
"transferStatus": "已被接收",
}
]
chat_router._postprocess_transfer_messages(merged)
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,63 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.chat_helpers import _infer_transfer_status_text
class TestTransferStatusText(unittest.TestCase):
def test_paysubtype_3_sent_side(self):
status = _infer_transfer_status_text(
is_sent=True,
paysubtype="3",
receivestatus="",
sendertitle="",
receivertitle="",
senderdes="",
receiverdes="",
)
self.assertEqual(status, "已被接收")
def test_paysubtype_3_received_side(self):
status = _infer_transfer_status_text(
is_sent=False,
paysubtype="3",
receivestatus="",
sendertitle="",
receivertitle="",
senderdes="",
receiverdes="",
)
self.assertEqual(status, "已收款")
def test_receivestatus_1_sent_side(self):
status = _infer_transfer_status_text(
is_sent=True,
paysubtype="1",
receivestatus="1",
sendertitle="",
receivertitle="",
senderdes="",
receiverdes="",
)
self.assertEqual(status, "已被接收")
def test_receivestatus_1_received_side(self):
status = _infer_transfer_status_text(
is_sent=False,
paysubtype="1",
receivestatus="1",
sendertitle="",
receivertitle="",
senderdes="",
receiverdes="",
)
self.assertEqual(status, "已收款")
if __name__ == "__main__":
unittest.main()