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>
<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="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)">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
@@ -501,6 +507,7 @@
:fromAvatar="message.fromAvatar"
:from="message.from"
:isSent="message.isSent"
:variant="message.linkCardVariant || 'default'"
/>
<div v-else-if="message.renderType === 'file'"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
@@ -651,25 +658,55 @@
class="hidden"
></audio>
</div>
<div v-else class="line-clamp-2">
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
<span
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
:class="message.quoteTitle ? 'ml-1' : ''"
>
{{ message.quoteContent }}
</span>
<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.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
:class="message.quoteTitle ? 'ml-1' : ''"
>
{{ message.quoteContent }}
</span>
</div>
</template>
</div>
</div>
<div
v-if="isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
class="ml-2 my-2 flex-shrink-0 w-[45px] h-[45px] rounded overflow-hidden cursor-pointer"
v-if="isQuotedLink(message) && message.quoteThumbUrl && !message._quoteThumbError"
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)"
>
<img
:src="message.quoteImageUrl"
alt="引用图片"
class="w-full h-full object-contain"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
@error="onQuoteImageError(message)"
@@ -3226,12 +3263,31 @@ const isQuotedImage = (message) => {
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) => {
try {
if (message) message._quoteImageError = true
} catch {}
}
const onQuoteThumbError = (message) => {
try {
if (message) message._quoteThumbError = true
} catch {}
}
const playQuoteVoice = (message) => {
playVoice({ id: getQuoteVoiceId(message) })
}
@@ -3969,7 +4025,7 @@ const getTransferTitle = (message) => {
if (message.transferStatus) return message.transferStatus
switch (paySubType) {
case '1': return '转账'
case '3': return message.isSent ? '已收' : '已被接收'
case '3': return message.isSent ? '已被接收' : '已收'
case '8': return '发起转账'
case '4': return '已退还'
case '9': return '已被退还'
@@ -4136,6 +4192,7 @@ const loadSessionsForSelectedAccount = async () => {
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
isTop: !!s.isTop,
username: s.username
}))
@@ -4209,6 +4266,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
isTop: !!s.isTop,
username: s.username
}))
@@ -4401,6 +4459,19 @@ const normalizeMessage = (msg) => {
].filter(Boolean)
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 {
id: msg.id,
@@ -4443,17 +4514,22 @@ const normalizeMessage = (msg) => {
quoteVoiceLength: msg.quoteVoiceLength || '',
quoteVoiceUrl,
quoteImageUrl: quoteImageUrl || '',
quoteThumbUrl: quoteThumbUrl || '',
_quoteImageError: false,
_quoteThumbError: false,
amount: msg.amount || '',
coverUrl: msg.coverUrl || '',
fileSize: msg.fileSize || '',
fileMd5: msg.fileMd5 || '',
paySubType: msg.paySubType || '',
transferStatus: msg.transferStatus || '',
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款',
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
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(),
fromUsername,
fromAvatar,
@@ -5331,7 +5407,8 @@ const LinkCard = defineComponent({
preview: { type: String, default: '' },
fromAvatar: { type: String, default: '' },
from: { type: String, default: '' },
isSent: { type: Boolean, default: false }
isSent: { type: Boolean, default: false },
variant: { type: String, default: 'default' }
},
setup(props) {
const getFromText = () => {
@@ -5356,6 +5433,65 @@ const LinkCard = defineComponent({
return t ? (Array.from(t)[0] || '') : ''
})()
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(
'a',
{
@@ -5930,11 +6066,11 @@ const LinkCard = defineComponent({
/* 已领取的转账样式 */
.wechat-transfer-received {
background: #f8e2c6;
background: #FDCE9D;
}
.wechat-transfer-received::after {
background: #f8e2c6;
background: #FDCE9D;
}
.wechat-transfer-received .wechat-transfer-amount,
@@ -6258,6 +6394,111 @@ const LinkCard = defineComponent({
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 {
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 pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
from urllib.parse import quote, urlparse
from fastapi import HTTPException
@@ -618,6 +618,39 @@ def _normalize_xml_url(url: str) -> str:
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:
if not xml_text or not tag:
return ""
@@ -689,6 +722,65 @@ def _extract_refermsg_block(xml_text: str) -> str:
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(
is_sent: bool,
paysubtype: str,
@@ -702,7 +794,7 @@ def _infer_transfer_status_text(
rs = str(receivestatus or "").strip()
if rs == "1":
return "已收款"
return "被接收" if is_sent else "收款"
if rs == "2":
return "已退还"
if rs == "3":
@@ -718,7 +810,7 @@ def _infer_transfer_status_text(
if t == "8":
return "发起转账"
if t == "3":
return "已收" if is_sent else "被接"
return "被接" if is_sent else "已收"
if t == "1":
return "转账"
@@ -770,10 +862,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str:
if not xml_text:
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:
return v
v = _extract_xml_attr(xml_text, "fromusername")
v = _extract_xml_attr(probe_text, "fromusername")
if v:
return v
return ""
@@ -846,6 +950,12 @@ def _parse_app_message(text: str) -> dict[str, Any]:
if app_type in (5, 68) and url:
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 {
"renderType": "link",
"content": des or title or "[链接]",
@@ -854,6 +964,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"thumbUrl": thumb_url or "",
"from": str(source_display_name or "").strip(),
"fromUsername": str(source_username or "").strip(),
"linkType": link_type,
"linkStyle": link_style,
}
if app_type in (6, 74):
@@ -907,7 +1019,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
or ""
)
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")
rt = (reply_text or "").strip()
@@ -924,6 +1036,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
refer_content = rest
t = str(refer_type or "").strip()
quote_thumb_url = ""
quote_voice_length = ""
if t == "3":
refer_content = "[图片]"
@@ -944,8 +1057,29 @@ def _parse_app_message(text: str) -> dict[str, Any]:
except Exception:
quote_voice_length = ""
refer_content = "[语音]"
elif t == "49" and refer_content:
refer_content = f"[链接] {refer_content}".strip()
elif t == "57":
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 {
"renderType": "quote",
@@ -954,6 +1088,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"quoteTitle": refer_displayname or "",
"quoteContent": refer_content or "",
"quoteType": t,
"quoteThumbUrl": quote_thumb_url,
"quoteServerId": str(refer_svrid or "").strip(),
"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('"<')):
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
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)
if xml_sender:
sender_username = xml_sender
@@ -1838,6 +1973,9 @@ def _row_to_search_hit(
quote_username = ""
quote_title = ""
quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
amount = ""
pay_sub_type = ""
transfer_status = ""
@@ -1854,6 +1992,9 @@ def _row_to_search_hit(
url = str(parsed.get("url") or "")
quote_title = str(parsed.get("quoteTitle") 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 "")
amount = str(parsed.get("amount") 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 "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
quote_username = str(parsed.get("quoteUsername") or "")
elif local_type == 3:
render_type = "image"
@@ -1927,6 +2069,9 @@ def _row_to_search_hit(
url = str(parsed.get("url") or url)
quote_title = str(parsed.get("quoteTitle") or quote_title)
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)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
quote_username = str(parsed.get("quoteUsername") or quote_username)
@@ -1966,9 +2111,12 @@ def _row_to_search_hit(
"content": content_text,
"title": title,
"url": url,
"linkType": link_type,
"linkStyle": link_style,
"quoteUsername": quote_username,
"quoteTitle": quote_title,
"quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount,
"paySubType": pay_sub_type,
"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] = {}
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(
*,
account_dir: Path,
@@ -787,6 +792,82 @@ def _load_session_last_message_times(conn: sqlite3.Connection, usernames: list[s
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="实时消息同步到解密库(按会话增量)")
def sync_chat_realtime_messages(
request: Request,
@@ -2251,7 +2332,7 @@ def _append_full_messages_from_rows(
if is_group and sender_prefix and (not sender_username):
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)
if xml_sender:
sender_username = xml_sender
@@ -2287,6 +2368,9 @@ def _append_full_messages_from_rows(
quote_username = ""
quote_title = ""
quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = ""
quote_type = ""
quote_voice_length = ""
@@ -2313,6 +2397,9 @@ def _append_full_messages_from_rows(
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") 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 "[引用消息]")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
@@ -2468,6 +2558,20 @@ def _append_full_messages_from_rows(
local_id=local_id,
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 = "[视频]"
elif local_type == 47:
render_type = "emoji"
@@ -2527,6 +2631,9 @@ def _append_full_messages_from_rows(
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)
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)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -2578,6 +2685,8 @@ def _append_full_messages_from_rows(
"content": content_text,
"title": title,
"url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
@@ -2601,6 +2710,7 @@ def _append_full_messages_from_rows(
"quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title,
"quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount,
"coverUrl": cover_url,
"fileSize": file_size,
@@ -2619,6 +2729,111 @@ def _append_full_messages_from_rows(
pass
def _postprocess_transfer_messages(merged: list[dict[str, Any]]) -> None:
# 后处理:关联转账消息的最终状态
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
#
# Windows 微信在部分场景会为同一笔转账记录两条消息:
# - paysubtype=1/8发起/待收款(这里回填为“已被接收”)
# - paysubtype=3收款确认展示为“已收款”
#
# 这两条消息的 isSent 并不能稳定表示“付款方/收款方视角”,因此这里以 transferId 关联结果为准:
# - 将原始转账消息1/8回填为“已被接收”
# - 若同一 transferId 同时存在原始消息与 paysubtype=3 消息,则将 paysubtype=3 的那条校正为“已收款”
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
returned_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:
if m.get("renderType") != "transfer":
continue
pst = str(m.get("paySubType") or "")
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
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 tid:
returned_transfer_ids.add(tid)
if amt:
returned_amounts_with_time.append((amt, ts))
elif pst == "3": # 已收款状态
if tid:
received_transfer_ids.add(tid)
if amt:
received_amounts_with_time.append((amt, ts))
backfilled_message_ids: set[str] = set()
for m in merged:
if m.get("renderType") != "transfer":
continue
pst = str(m.get("paySubType") or "")
if pst not in ("1", "8"):
continue
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"
m["transferStatus"] = "已被接收"
mid = str(m.get("id") or "").strip()
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]],
@@ -2631,75 +2846,7 @@ def _postprocess_full_messages(
contact_db_path: Path,
head_image_db_path: Path,
) -> None:
# 后处理:关联转账消息的最终状态
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
# 收集已退还和已收款的转账ID和金额
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
for m in merged:
if m.get("renderType") == "transfer":
pst = str(m.get("paySubType") or "")
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0)
if pst in ("4", "9"): # 退还状态
if tid:
returned_transfer_ids.add(tid)
if amt:
returned_amounts_with_time.append((amt, ts))
elif pst == "3": # 已收款状态
if tid:
received_transfer_ids.add(tid)
if amt:
received_amounts_with_time.append((amt, ts))
# 更新原始转账消息的状态
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 "已被接收"
_postprocess_transfer_messages(merged)
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
@@ -3074,20 +3221,45 @@ def list_chat_sessions(
finally:
sconn.close()
filtered: list[sqlite3.Row] = []
usernames: list[str] = []
filtered: list[Any] = []
for r in rows:
username = r["username"] or ""
username = _session_row_get(r, "username", "") or ""
if not username:
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
if not _should_keep_session(username, include_official=include_official):
continue
filtered.append(r)
usernames.append(username)
if len(filtered) >= int(limit):
break
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)
contact_rows = _load_contact_rows(contact_db_path, usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
@@ -3121,12 +3293,20 @@ def list_chat_sessions(
need_display = list(dict.fromkeys(need_display))
need_avatar = list(dict.fromkeys(need_avatar))
if need_display or need_avatar:
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
with wcdb_conn.lock:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
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:
if need_display:
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
if need_avatar:
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
except Exception:
wcdb_display_names = {}
wcdb_avatar_urls = {}
@@ -3296,6 +3476,7 @@ def list_chat_sessions(
"lastMessageTime": last_time,
"unreadCount": int(r["unread_count"] or 0),
"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):
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)
if xml_sender:
sender_username = xml_sender
@@ -3472,6 +3653,9 @@ def _collect_chat_messages(
quote_username = ""
quote_title = ""
quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = ""
quote_type = ""
quote_voice_length = ""
@@ -3498,6 +3682,9 @@ def _collect_chat_messages(
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
@@ -3541,6 +3728,9 @@ def _collect_chat_messages(
content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
@@ -3640,6 +3830,11 @@ def _collect_chat_messages(
local_id=local_id,
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 = "[视频]"
elif local_type == 47:
render_type = "emoji"
@@ -3701,6 +3896,9 @@ def _collect_chat_messages(
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)
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)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -3758,6 +3956,8 @@ def _collect_chat_messages(
"content": content_text,
"title": title,
"url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
@@ -3781,6 +3981,7 @@ def _collect_chat_messages(
"quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title,
"quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount,
"coverUrl": cover_url,
"fileSize": file_size,
@@ -4139,7 +4340,7 @@ def list_chat_messages(
if is_group and 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)
if xml_sender:
sender_username = xml_sender
@@ -4175,6 +4376,9 @@ def list_chat_messages(
quote_username = ""
quote_title = ""
quote_content = ""
quote_thumb_url = ""
link_type = ""
link_style = ""
quote_server_id = ""
quote_type = ""
quote_voice_length = ""
@@ -4201,6 +4405,9 @@ def list_chat_messages(
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
@@ -4244,6 +4451,9 @@ def list_chat_messages(
content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") 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_server_id = str(parsed.get("quoteServerId") 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)
quote_title = str(parsed.get("quoteTitle") or quote_title)
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)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
@@ -4450,6 +4663,8 @@ def list_chat_messages(
"content": content_text,
"title": title,
"url": url,
"linkType": link_type,
"linkStyle": link_style,
"from": from_name,
"fromUsername": from_username,
"recordItem": record_item,
@@ -4473,6 +4688,7 @@ def list_chat_messages(
"quoteVoiceLength": str(quote_voice_length).strip(),
"quoteTitle": quote_title,
"quoteContent": quote_content,
"quoteThumbUrl": quote_thumb_url,
"amount": amount,
"coverUrl": cover_url,
"fileSize": file_size,
@@ -4509,81 +4725,38 @@ def list_chat_messages(
deduped.append(m)
merged = deduped
# 后处理:关联转账消息的最终状态
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
_postprocess_transfer_messages(merged)
# 收集已退还和已收款的转账ID和金额
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
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)
for m in merged:
if m.get("renderType") == "transfer":
pst = str(m.get("paySubType") or "")
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0)
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))
if pst in ("4", "9"): # 退还状态
if tid:
returned_transfer_ids.add(tid)
if amt:
returned_amounts_with_time.append((amt, ts))
elif pst == "3": # 已收款状态
if tid:
received_transfer_ids.add(tid)
if amt:
received_amounts_with_time.append((amt, ts))
# Hot path optimization: only enrich the page we return.
if not page:
return {
"status": "success",
"account": account_dir.name,
"username": username,
"total": int(offset) + (1 if has_more_global else 0),
"hasMore": bool(has_more_global),
"messages": [],
}
# 更新原始转账消息的状态
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 "已被接收"
messages_window = page
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
missing_from_names = [
str(m.get("from") or "").strip()
for m in merged
for m in messages_window
if str(m.get("renderType") or "").strip() == "link"
and str(m.get("from") or "").strip()
and not str(m.get("fromUsername") or "").strip()
@@ -4591,7 +4764,7 @@ def list_chat_messages(
if missing_from_names:
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
if name_to_username:
for m in merged:
for m in messages_window:
if str(m.get("fromUsername") or "").strip():
continue
if str(m.get("renderType") or "").strip() != "link":
@@ -4600,10 +4773,33 @@ def list_chat_messages(
if fn and fn in name_to_username:
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(
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)
@@ -4645,7 +4841,7 @@ def list_chat_messages(
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 (not str(m.get("from") or "").strip()) and 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:
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 {
"status": "success",
"account": account_dir.name,
@@ -5762,10 +5946,21 @@ async def get_chat_messages_around(
my_rowid = None
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 = (
"SELECT "
"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 "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"WHERE m.local_id = ? "
@@ -5774,7 +5969,9 @@ async def get_chat_messages_around(
sql_anchor_no_join = (
"SELECT "
"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 "
"WHERE m.local_id = ? "
"LIMIT 1"
@@ -5811,7 +6008,9 @@ async def get_chat_messages_around(
sql_before_with_join = (
"SELECT "
"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 "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
f"{where_before} "
@@ -5821,7 +6020,9 @@ async def get_chat_messages_around(
sql_before_no_join = (
"SELECT "
"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"{where_before} "
"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 = (
"SELECT "
"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 "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
f"{where_after} "
@@ -5841,7 +6044,9 @@ async def get_chat_messages_around(
sql_after_no_join = (
"SELECT "
"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"{where_after} "
"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()