mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
improvement(chat): 完善会话置顶与消息卡片解析展示
- 后端:会话列表支持置顶识别(isTop)并按置顶优先排序 - 后端:修正群聊 XML 发送者提取,避免 refermsg 嵌套误识别 - 后端:完善转账状态后处理与视频缩略图 MD5 回填(packed_info_data) - 后端:补充 quoteThumbUrl/linkType/linkStyle 字段链路 - 前端:新增置顶会话背景态、引用链接缩略图预览与 LinkCard cover 样式 - 测试:新增转账、置顶、引用解析与视频缩略图相关回归用例
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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 |
@@ -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("&", "&").strip()
|
return u.replace("&", "&").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,
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
93
tests/test_chat_realtime_video_thumb_md5_from_packed_info.py
Normal file
93
tests/test_chat_realtime_video_thumb_md5_from_packed_info.py
Normal 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()
|
||||||
|
|
||||||
211
tests/test_chat_sessions_pinning.py
Normal file
211
tests/test_chat_sessions_pinning.py
Normal 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()
|
||||||
|
|
||||||
23
tests/test_group_xml_sender_extraction.py
Normal file
23
tests/test_group_xml_sender_extraction.py
Normal 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()
|
||||||
115
tests/test_parse_app_message.py
Normal file
115
tests/test_parse_app_message.py
Normal 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()
|
||||||
68
tests/test_transfer_postprocess.py
Normal file
68
tests/test_transfer_postprocess.py
Normal 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()
|
||||||
|
|
||||||
63
tests/test_transfer_status_text.py
Normal file
63
tests/test_transfer_status_text.py
Normal 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()
|
||||||
Reference in New Issue
Block a user