From 548f3cf2c830adf7ffef8cf62b3b6653d728c967 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Wed, 11 Feb 2026 21:57:43 +0800 Subject: [PATCH] =?UTF-8?q?improvement(chat):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BD=AE=E9=A1=B6=E4=B8=8E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E8=A7=A3=E6=9E=90=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:会话列表支持置顶识别(isTop)并按置顶优先排序 - 后端:修正群聊 XML 发送者提取,避免 refermsg 嵌套误识别 - 后端:完善转账状态后处理与视频缩略图 MD5 回填(packed_info_data) - 后端:补充 quoteThumbUrl/linkType/linkStyle 字段链路 - 前端:新增置顶会话背景态、引用链接缩略图预览与 LinkCard cover 样式 - 测试:新增转账、置顶、引用解析与视频缩略图相关回归用例 --- .../images/wechat/wechat-trans-icon2.png | Bin 1541 -> 1633 bytes frontend/pages/chat/[[username]].vue | 275 ++++++++- .../images/wechat/wechat-trans-icon2.png | Bin 1541 -> 1633 bytes src/wechat_decrypt_tool/chat_helpers.py | 168 +++++- src/wechat_decrypt_tool/routers/chat.py | 551 ++++++++++++------ ...altime_video_thumb_md5_from_packed_info.py | 93 +++ tests/test_chat_sessions_pinning.py | 211 +++++++ tests/test_group_xml_sender_extraction.py | 23 + tests/test_parse_app_message.py | 115 ++++ tests/test_transfer_postprocess.py | 68 +++ tests/test_transfer_status_text.py | 63 ++ 11 files changed, 1367 insertions(+), 200 deletions(-) create mode 100644 tests/test_chat_realtime_video_thumb_md5_from_packed_info.py create mode 100644 tests/test_chat_sessions_pinning.py create mode 100644 tests/test_group_xml_sender_extraction.py create mode 100644 tests/test_parse_app_message.py create mode 100644 tests/test_transfer_postprocess.py create mode 100644 tests/test_transfer_status_text.py diff --git a/frontend/assets/images/wechat/wechat-trans-icon2.png b/frontend/assets/images/wechat/wechat-trans-icon2.png index b9f72da1464599556d2c5a63d76666ed9660619f..6d500a2ed72c9eb37e0d74ab4b5f2dad10b9f45e 100644 GIT binary patch literal 1633 zcmV-n2A=teP)Px*8%ab#R9HvVndw*5RT#&=&z*%CzyJY71tdTgm(0^?F-yxzJ7r~cI%TKRi~g^A zmDOoIm0E78r_#1#iW`C~iYOp3EHf}H^ShnrKC?3055~dM^Jcc+z4vpU=iX-<3Z4I0 zBNy?Ofr{4vMXNzBZ^BG^ZW;&-K>G)PXp`0+rMNjsklC^FiqGKIoAS zATVfeOx#oUL6uYBDqDeqlH`TvXMoTQz^^RM0@(z=E|j1T`rb9@;da}=wm_-04pjXV z;3`Zs?N|>m(g{6rJL7`%Pz_Mt2r7Lz@wwSa=)QAV$Mc}9pj7*e(PUJtIp{m*#e~+J zZH<@*l?znS0$TH^c^?rybP2lu@64M`dq$lesK&j3w_GOB7WD8n=(`uh#JPym16s2U zRNev*$@Koa&|Sv?Yd+=7sWs{`E7a> zR>$F#mP!V7xS^U}13YqJ^k^4!-`T~R+3uDcZcy#Bpwjg+!aoSB^KjDWBv7i`1uAP0 z$l-Q@Tn3@~7eFNfs|PMX-#wqOY62*g)Pm|?4DW0JR_BprKnyRT9AZiDO7n%){yi`~ z5?wzA$`5XQM_ka{6s-2|Vm@)Xa9u@Et^39Q28LjD97+Z?x3FrPsJ{0Mw6ANq@Dfd2 zxd~MLWSHX=w6Dj!7mYexP@4|`$_dPj!@By-1A!DG1-0p2fb)i#30POZj)Bsu&7gHp z$S%51K#$*gaG=au(G05DAv`yZLQnRKMpg6cKw*WP&C6db3nU*i4j`BhCa=d@edGgU zVUB?M)^DIt+d-|iyf5?|dHT-f9hBDZ1g&h8W5y0X1#Hw^g=S#29Ryjur!}qLm7wN) zl13qUk>I#E_f(+e8%!WDuIk#iAZzG~rpk@-#lN5AlMxA!d{Nl_q%u8Ia2VhGo+$9< z8IU#6uW7|vVAc=o%9nWqrMl-pW%V*YL$W5h0>~h0>xXhq2QPy3^r(iX444{*b^V9D zfG!1+LDa^#O_HvC-3&UWIRP&?^>*m)leUwTr9v_YHSPtiSZ5Ly(o-Xv z<11(By6aeaP^TBFzXcbGheHu%qPS$$Xd*DQIM{}lfWk_NI9*50hI0{)l+^gLSo!9!(4(E<#96su%+HB) zHaC?Efz7e1^({G4oHkTl0tqp{+SvlTAxltXST zJO6Zw)e)m9T7zmQDdw^S)?I*TdJL`+mHDL6xf=FFTP)zF z>+L`9TPe-O9j$#_>UTEx7y4qeU)nASaI9qBHXK74s|>VG^k zO)xVFJ<<;LBoRlRb-nNd_BEPn#=;5^`&{l$yNW>^)#606hB=E0j+q`kHyoBKxq&Wq f)g_Gk-+=xHBdQ2f4&CYN00000NkvXXu0mjfg?J?x literal 1541 zcmV+g2KxDlP)Px)zez+vRA@uRS!Y)pM-;s?DhNdHEu0X?B{DXW#@mx&Spi4d{4hfSfYSbr$9_0~F6YThlbFJK#z~jV?lsE&++*){UJC;FJM= za_EFTl;6S1S1=cQFqa3B4iFi*8kvQf`Ur?6y{>R|4CFH~*QbF1#4_o5P>yF*3NWYJ zq3hi>k$|h|+u|1W;Z$LAJJ#f;8cAc&F>AGnH9*DcYt8w4n6rOFWpxHzC!pu=1IbY< z&={D@eVE+a8+QoZs?7wQfSO#BLjcN(FXUj3{tQ$`IPh2!df{^*p0Ybv3UH2J0HxkD zgXbv8G3dE_Ky1kVyOf7>_zZCTEg1}4$DtR#s0X0QAHrm}qq{|?*3fZ?JC4lQ6~%Kn znPg|AJwysEt;Q>lpOPLz`R6*Kqb9_XR{(LX?7D{?=Na z*=+Ck_Q2KH3e?oPJ>}60nCp|S_UzXpDN@rPTRewbFoiSu-lB26_@ym0^zGeW`?Y_d z9J-~s*th*n^PtvOzXOz(3T?8leSot$N z{^wTU6hJ}EW*HRrI%sYX1U$U}HFH~FNQNt4d0mAO%t)3AZ+{vTUaK)z)BJ;hcktBT zRrovG4kirAa<9M&?(uOo|0z^z5~vj6?EN+{;A-|R)QGLf0-7#TEd}nUKltAI`T0AT zvmGyRz4R537?CO^)oK8v8_PDz=qDBQXp$bxTrar@eT$ zpvARZZ>Tk19#_$e52QcfEyf%@Zx1_`fXroKD6t%Gg@9L3vv(r9MAABC+K@Ot*ZF+CQvO#azyD{66t!Y>kZDi4SQSfr1*3MzCWPF z$5$Q!v4r$1>|4CRC)c6IS2|CJzTh_jCi8$G!`H+XE7IU&Svlo zo}=fGO9-vUQ7ytbV9JZ;9`5E$(F{%jUh5LQy$XYlCgFsOU6|8GPl2RLVg< z#pO`wcFfb2LT9pkW~u30mcyZFmVBq;(`30p(9}kT?%J$*SJSjy51_4oKeQRF;SSo; z9X2-aqNykpna~&;Kyu7h#KWzgL3`Y!R_-OQw{V9lW;;Zs!_#7@8G4^kujcFwcYhI1 z#~(OP)8JDquPn3w$M9Stnr{34mt}Tiyew1e%n18W+IMb)I#J_g*(t->d+fQex2Gq@ zSD_|9w6Fq*nStOY%dkiy%F+|&+E=z^#-m za{`HdQgA$Z+;ZRpXS2w=lY*khcPMSMuQHhBi=^r_HY%QzwD&eygs0=+ljjXFw>Lfn9qr`omdAS%J=n$l)A(R
@@ -501,6 +507,7 @@ :fromAvatar="message.fromAvatar" :from="message.from" :isSent="message.isSent" + :variant="message.linkCardVariant || 'default'" />
-
- {{ message.quoteTitle }}: - - {{ message.quoteContent }} - +
+ +
+ 引用链接缩略图 +
+
引用图片 { return false } +const isQuotedLink = (message) => { + const t = String(message?.quoteType || '').trim() + if (t === '49') return true + return /^\[链接\]\s*/.test(String(message?.quoteContent || '').trim()) +} + +const getQuotedLinkText = (message) => { + const raw = String(message?.quoteContent || '').trim() + if (!raw) return '' + const stripped = raw.replace(/^\[链接\]\s*/u, '').trim() + return stripped || raw +} + const onQuoteImageError = (message) => { try { if (message) message._quoteImageError = true } catch {} } +const onQuoteThumbError = (message) => { + try { + if (message) message._quoteThumbError = true + } catch {} +} + const playQuoteVoice = (message) => { playVoice({ id: getQuoteVoiceId(message) }) } @@ -3969,7 +4025,7 @@ const getTransferTitle = (message) => { if (message.transferStatus) return message.transferStatus switch (paySubType) { case '1': return '转账' - case '3': return message.isSent ? '已收款' : '已被接收' + case '3': return message.isSent ? '已被接收' : '已收款' case '8': return '发起转账' case '4': return '已退还' case '9': return '已被退还' @@ -4136,6 +4192,7 @@ const loadSessionsForSelectedAccount = async () => { lastMessageTime: s.lastMessageTime || '', unreadCount: s.unreadCount || 0, isGroup: !!s.isGroup, + isTop: !!s.isTop, username: s.username })) @@ -4209,6 +4266,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => { lastMessageTime: s.lastMessageTime || '', unreadCount: s.unreadCount || 0, isGroup: !!s.isGroup, + isTop: !!s.isTop, username: s.username })) @@ -4401,6 +4459,19 @@ const normalizeMessage = (msg) => { ].filter(Boolean) return parts.length ? `${mediaBase}/api/chat/media/image?${parts.join('&')}` : '' })() + const quoteThumbUrl = (() => { + const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : '' + if (!raw) return '' + if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw + if (!/^https?:\/\//i.test(raw)) return raw + try { + const host = new URL(raw).hostname.toLowerCase() + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { + return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}` + } + } catch {} + return raw + })() return { id: msg.id, @@ -4443,17 +4514,22 @@ const normalizeMessage = (msg) => { quoteVoiceLength: msg.quoteVoiceLength || '', quoteVoiceUrl, quoteImageUrl: quoteImageUrl || '', + quoteThumbUrl: quoteThumbUrl || '', _quoteImageError: false, + _quoteThumbError: false, amount: msg.amount || '', coverUrl: msg.coverUrl || '', fileSize: msg.fileSize || '', fileMd5: msg.fileMd5 || '', paySubType: msg.paySubType || '', transferStatus: msg.transferStatus || '', - transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款', + transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收', voiceUrl: normalizedVoiceUrl || '', voiceDuration: msg.voiceLength || msg.voiceDuration || '', preview: normalizedLinkPreviewUrl || '', + linkType: String(msg.linkType || '').trim(), + linkStyle: String(msg.linkStyle || '').trim(), + linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default', from: String(msg.from || '').trim(), fromUsername, fromAvatar, @@ -5331,7 +5407,8 @@ const LinkCard = defineComponent({ preview: { type: String, default: '' }, fromAvatar: { type: String, default: '' }, from: { type: String, default: '' }, - isSent: { type: Boolean, default: false } + isSent: { type: Boolean, default: false }, + variant: { type: String, default: 'default' } }, setup(props) { const getFromText = () => { @@ -5356,6 +5433,65 @@ const LinkCard = defineComponent({ return t ? (Array.from(t)[0] || '') : '' })() const fromAvatarUrl = String(props.fromAvatar || '').trim() + const isCoverVariant = String(props.variant || '').trim() === 'cover' + + if (isCoverVariant) { + const fromRow = h('div', { class: 'wechat-link-cover-from' }, [ + h('div', { class: 'wechat-link-cover-from-avatar', 'aria-hidden': 'true' }, [ + fromAvatarText || '\u200B', + fromAvatarUrl ? h('img', { + src: fromAvatarUrl, + alt: '', + class: 'wechat-link-cover-from-avatar-img', + referrerpolicy: 'no-referrer', + onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} } + }) : null + ].filter(Boolean)), + h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B') + ]) + + return h( + 'a', + { + href: props.href, + target: '_blank', + rel: 'noreferrer', + class: [ + 'wechat-link-card-cover', + 'wechat-special-card', + 'msg-radius', + props.isSent ? 'wechat-special-sent-side' : '' + ].filter(Boolean).join(' '), + style: { + width: '137px', + minWidth: '137px', + maxWidth: '137px', + display: 'flex', + flexDirection: 'column', + boxSizing: 'border-box', + flex: '0 0 auto', + background: '#fff', + border: 'none', + boxShadow: 'none', + textDecoration: 'none', + outline: 'none' + } + }, + [ + props.preview ? h('div', { class: 'wechat-link-cover-image-wrap' }, [ + h('img', { + src: props.preview, + alt: props.heading || '链接封面', + class: 'wechat-link-cover-image', + referrerpolicy: 'no-referrer' + }), + fromRow, + ]) : fromRow, + h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href) + ].filter(Boolean) + ) + } + return h( 'a', { @@ -5930,11 +6066,11 @@ const LinkCard = defineComponent({ /* 已领取的转账样式 */ .wechat-transfer-received { - background: #f8e2c6; + background: #FDCE9D; } .wechat-transfer-received::after { - background: #f8e2c6; + background: #FDCE9D; } .wechat-transfer-received .wechat-transfer-amount, @@ -6258,6 +6394,111 @@ const LinkCard = defineComponent({ white-space: nowrap; } +/* 链接封面卡片(170x230 图 + 60 底栏) */ +:deep(.wechat-link-card-cover) { + width: 137px; + min-width: 137px; + max-width: 137px; + background: #fff; + display: flex; + flex-direction: column; + box-sizing: border-box; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +:deep(.wechat-link-card-cover:hover) { + background: #f5f5f5; +} + +:deep(.wechat-link-cover-image-wrap) { + width: 137px; + height: 180px; + position: relative; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: #f2f2f2; + flex-shrink: 0; +} + +:deep(.wechat-link-cover-image) { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; +} + +/* 仅公众号封面卡片去掉菱形尖角,其它消息保持原样 */ +:deep(.wechat-link-card-cover.wechat-special-card)::after { + content: none !important; +} + +:deep(.wechat-link-cover-from) { + height: 30px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: transparent; + flex-shrink: 0; +} + +:deep(.wechat-link-cover-from-avatar) { + width: 18px; + height: 18px; + border-radius: 50%; + background: #111; + color: #fff; + font-size: 11px; + line-height: 18px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +:deep(.wechat-link-cover-from-avatar-img) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:deep(.wechat-link-cover-from-name) { + font-size: 12px; + color: #f3f3f3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +:deep(.wechat-link-cover-title) { + height: 50px; + padding: 7px 10px 0; + box-sizing: border-box; + font-size: 12px; + line-height: 1.24; + color: #1a1a1a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + flex-shrink: 0; +} + /* 隐私模式模糊效果 */ .privacy-blur { filter: blur(9px); diff --git a/frontend/public/assets/images/wechat/wechat-trans-icon2.png b/frontend/public/assets/images/wechat/wechat-trans-icon2.png index b9f72da1464599556d2c5a63d76666ed9660619f..6d500a2ed72c9eb37e0d74ab4b5f2dad10b9f45e 100644 GIT binary patch literal 1633 zcmV-n2A=teP)Px*8%ab#R9HvVndw*5RT#&=&z*%CzyJY71tdTgm(0^?F-yxzJ7r~cI%TKRi~g^A zmDOoIm0E78r_#1#iW`C~iYOp3EHf}H^ShnrKC?3055~dM^Jcc+z4vpU=iX-<3Z4I0 zBNy?Ofr{4vMXNzBZ^BG^ZW;&-K>G)PXp`0+rMNjsklC^FiqGKIoAS zATVfeOx#oUL6uYBDqDeqlH`TvXMoTQz^^RM0@(z=E|j1T`rb9@;da}=wm_-04pjXV z;3`Zs?N|>m(g{6rJL7`%Pz_Mt2r7Lz@wwSa=)QAV$Mc}9pj7*e(PUJtIp{m*#e~+J zZH<@*l?znS0$TH^c^?rybP2lu@64M`dq$lesK&j3w_GOB7WD8n=(`uh#JPym16s2U zRNev*$@Koa&|Sv?Yd+=7sWs{`E7a> zR>$F#mP!V7xS^U}13YqJ^k^4!-`T~R+3uDcZcy#Bpwjg+!aoSB^KjDWBv7i`1uAP0 z$l-Q@Tn3@~7eFNfs|PMX-#wqOY62*g)Pm|?4DW0JR_BprKnyRT9AZiDO7n%){yi`~ z5?wzA$`5XQM_ka{6s-2|Vm@)Xa9u@Et^39Q28LjD97+Z?x3FrPsJ{0Mw6ANq@Dfd2 zxd~MLWSHX=w6Dj!7mYexP@4|`$_dPj!@By-1A!DG1-0p2fb)i#30POZj)Bsu&7gHp z$S%51K#$*gaG=au(G05DAv`yZLQnRKMpg6cKw*WP&C6db3nU*i4j`BhCa=d@edGgU zVUB?M)^DIt+d-|iyf5?|dHT-f9hBDZ1g&h8W5y0X1#Hw^g=S#29Ryjur!}qLm7wN) zl13qUk>I#E_f(+e8%!WDuIk#iAZzG~rpk@-#lN5AlMxA!d{Nl_q%u8Ia2VhGo+$9< z8IU#6uW7|vVAc=o%9nWqrMl-pW%V*YL$W5h0>~h0>xXhq2QPy3^r(iX444{*b^V9D zfG!1+LDa^#O_HvC-3&UWIRP&?^>*m)leUwTr9v_YHSPtiSZ5Ly(o-Xv z<11(By6aeaP^TBFzXcbGheHu%qPS$$Xd*DQIM{}lfWk_NI9*50hI0{)l+^gLSo!9!(4(E<#96su%+HB) zHaC?Efz7e1^({G4oHkTl0tqp{+SvlTAxltXST zJO6Zw)e)m9T7zmQDdw^S)?I*TdJL`+mHDL6xf=FFTP)zF z>+L`9TPe-O9j$#_>UTEx7y4qeU)nASaI9qBHXK74s|>VG^k zO)xVFJ<<;LBoRlRb-nNd_BEPn#=;5^`&{l$yNW>^)#606hB=E0j+q`kHyoBKxq&Wq f)g_Gk-+=xHBdQ2f4&CYN00000NkvXXu0mjfg?J?x literal 1541 zcmV+g2KxDlP)Px)zez+vRA@uRS!Y)pM-;s?DhNdHEu0X?B{DXW#@mx&Spi4d{4hfSfYSbr$9_0~F6YThlbFJK#z~jV?lsE&++*){UJC;FJM= za_EFTl;6S1S1=cQFqa3B4iFi*8kvQf`Ur?6y{>R|4CFH~*QbF1#4_o5P>yF*3NWYJ zq3hi>k$|h|+u|1W;Z$LAJJ#f;8cAc&F>AGnH9*DcYt8w4n6rOFWpxHzC!pu=1IbY< z&={D@eVE+a8+QoZs?7wQfSO#BLjcN(FXUj3{tQ$`IPh2!df{^*p0Ybv3UH2J0HxkD zgXbv8G3dE_Ky1kVyOf7>_zZCTEg1}4$DtR#s0X0QAHrm}qq{|?*3fZ?JC4lQ6~%Kn znPg|AJwysEt;Q>lpOPLz`R6*Kqb9_XR{(LX?7D{?=Na z*=+Ck_Q2KH3e?oPJ>}60nCp|S_UzXpDN@rPTRewbFoiSu-lB26_@ym0^zGeW`?Y_d z9J-~s*th*n^PtvOzXOz(3T?8leSot$N z{^wTU6hJ}EW*HRrI%sYX1U$U}HFH~FNQNt4d0mAO%t)3AZ+{vTUaK)z)BJ;hcktBT zRrovG4kirAa<9M&?(uOo|0z^z5~vj6?EN+{;A-|R)QGLf0-7#TEd}nUKltAI`T0AT zvmGyRz4R537?CO^)oK8v8_PDz=qDBQXp$bxTrar@eT$ zpvARZZ>Tk19#_$e52QcfEyf%@Zx1_`fXroKD6t%Gg@9L3vv(r9MAABC+K@Ot*ZF+CQvO#azyD{66t!Y>kZDi4SQSfr1*3MzCWPF z$5$Q!v4r$1>|4CRC)c6IS2|CJzTh_jCi8$G!`H+XE7IU&Svlo zo}=fGO9-vUQ7ytbV9JZ;9`5E$(F{%jUh5LQy$XYlCgFsOU6|8GPl2RLVg< z#pO`wcFfb2LT9pkW~u30mcyZFmVBq;(`30p(9}kT?%J$*SJSjy51_4oKeQRF;SSo; z9X2-aqNykpna~&;Kyu7h#KWzgL3`Y!R_-OQw{V9lW;;Zs!_#7@8G4^kujcFwcYhI1 z#~(OP)8JDquPn3w$M9Stnr{34mt}Tiyew1e%n18W+IMb)I#J_g*(t->d+fQex2Gq@ zSD_|9w6Fq*nStOY%dkiy%F+|&+E=z^#-m za{`HdQgA$Z+;ZRpXS2w=lY*khcPMSMuQHhBi=^r_HY%QzwD&eygs0=+ljjXFw>Lfn9qr`omdAS%J=n$l)A(R str: 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: if not xml_text or not tag: return "" @@ -689,6 +722,65 @@ def _extract_refermsg_block(xml_text: str) -> str: return (m.group(1) or "").strip() if m else "" +def _extract_refermsg_content(refer_block: str) -> str: + if not refer_block: + return "" + + cdata_match = re.search( + r"]*>\s*\s*", + 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 " str: + candidate = str(raw_content or "").strip() + if not candidate: + return "" + + probes = [candidate] + + if candidate.startswith("wxid_"): + colon = candidate.find(":") + if 0 < colon <= 64: + rest = candidate[colon + 1 :].strip() + if rest: + probes.append(rest) + + for probe in probes: + for key in ("thumburl", "cdnthumburl", "cdnthumurl", "coverurl", "cover"): + value = _normalize_xml_url(_extract_xml_tag_or_attr(probe, key)) + if value: + return value + + return "" + + def _infer_transfer_status_text( is_sent: bool, paysubtype: str, @@ -702,7 +794,7 @@ def _infer_transfer_status_text( rs = str(receivestatus or "").strip() if rs == "1": - return "已收款" + return "已被接收" if is_sent else "已收款" if rs == "2": return "已退还" if rs == "3": @@ -718,7 +810,7 @@ def _infer_transfer_status_text( if t == "8": return "发起转账" if t == "3": - return "已收款" if is_sent else "已被接收" + return "已被接收" if is_sent else "已收款" if t == "1": return "转账" @@ -770,10 +862,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str: if not xml_text: return "" - v = _extract_xml_tag_text(xml_text, "fromusername") + probe_text = xml_text + try: + # Avoid picking nested quoted-message sender from . + probe_text = re.sub( + r"(]*>.*?)", + "", + xml_text, + flags=re.IGNORECASE | re.DOTALL, + ) + except Exception: + probe_text = xml_text + + v = _extract_xml_tag_text(probe_text, "fromusername") if v: return v - v = _extract_xml_attr(xml_text, "fromusername") + v = _extract_xml_attr(probe_text, "fromusername") if v: return v return "" @@ -846,6 +950,12 @@ def _parse_app_message(text: str) -> dict[str, Any]: if app_type in (5, 68) and url: thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl")) + link_type, link_style = _classify_link_share( + app_type=app_type, + url=url, + source_username=str(source_username or "").strip(), + desc=str(des or "").strip(), + ) return { "renderType": "link", "content": des or title or "[链接]", @@ -854,6 +964,8 @@ def _parse_app_message(text: str) -> dict[str, Any]: "thumbUrl": thumb_url or "", "from": str(source_display_name or "").strip(), "fromUsername": str(source_username or "").strip(), + "linkType": link_type, + "linkStyle": link_style, } if app_type in (6, 74): @@ -907,7 +1019,7 @@ def _parse_app_message(text: str) -> dict[str, Any]: or "" ) refer_svrid = _extract_xml_tag_or_attr(refer_block, "svrid") - refer_content = _extract_xml_tag_text(refer_block, "content") + refer_content = _extract_refermsg_content(refer_block) refer_type = _extract_xml_tag_or_attr(refer_block, "type") rt = (reply_text or "").strip() @@ -924,6 +1036,7 @@ def _parse_app_message(text: str) -> dict[str, Any]: refer_content = rest t = str(refer_type or "").strip() + quote_thumb_url = "" quote_voice_length = "" if t == "3": refer_content = "[图片]" @@ -944,8 +1057,29 @@ def _parse_app_message(text: str) -> dict[str, Any]: except Exception: quote_voice_length = "" refer_content = "[语音]" - elif t == "49" and refer_content: - refer_content = f"[链接] {refer_content}".strip() + elif t == "57": + summarized = _summarize_nested_quote_content(str(refer_content or "")) + if summarized: + refer_content = summarized + elif str(refer_content or "").lstrip().startswith("<"): + refer_content = "[引用消息]" + elif t in {"49", "5", "68"}: + raw_link_content = str(refer_content or "").strip() + summarized = _summarize_nested_quote_content(raw_link_content) + link_text = str(summarized or raw_link_content).strip() + quote_thumb_url = _extract_nested_quote_thumb_url(raw_link_content) + + if link_text.startswith("wxid_"): + colon = link_text.find(":") + if 0 < colon <= 64: + maybe_rest = link_text[colon + 1 :].strip() + if maybe_rest: + second_try = _summarize_nested_quote_content(maybe_rest) + link_text = str(second_try or maybe_rest).strip() + if not quote_thumb_url: + quote_thumb_url = _extract_nested_quote_thumb_url(maybe_rest) + + refer_content = f"[链接] {link_text}".strip() if link_text else "[链接]" return { "renderType": "quote", @@ -954,6 +1088,7 @@ def _parse_app_message(text: str) -> dict[str, Any]: "quoteTitle": refer_displayname or "", "quoteContent": refer_content or "", "quoteType": t, + "quoteThumbUrl": quote_thumb_url, "quoteServerId": str(refer_svrid or "").strip(), "quoteVoiceLength": quote_voice_length, } @@ -1818,10 +1953,10 @@ def _row_to_search_hit( if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')): sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username) - if is_group and sender_prefix: + if is_group and sender_prefix and (not sender_username): sender_username = sender_prefix - if is_group and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')): + if is_group and (not sender_username) and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')): xml_sender = _extract_sender_from_group_xml(raw_text) if xml_sender: sender_username = xml_sender @@ -1838,6 +1973,9 @@ def _row_to_search_hit( quote_username = "" quote_title = "" quote_content = "" + quote_thumb_url = "" + link_type = "" + link_style = "" amount = "" pay_sub_type = "" transfer_status = "" @@ -1854,6 +1992,9 @@ def _row_to_search_hit( url = str(parsed.get("url") or "") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") amount = str(parsed.get("amount") or "") pay_sub_type = str(parsed.get("paySubType") or "") @@ -1878,6 +2019,7 @@ def _row_to_search_hit( content_text = str(parsed.get("content") or "[引用消息]") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") quote_username = str(parsed.get("quoteUsername") or "") elif local_type == 3: render_type = "image" @@ -1927,6 +2069,9 @@ def _row_to_search_hit( url = str(parsed.get("url") or url) quote_title = str(parsed.get("quoteTitle") or quote_title) quote_content = str(parsed.get("quoteContent") or quote_content) + quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url) + link_type = str(parsed.get("linkType") or link_type) + link_style = str(parsed.get("linkStyle") or link_style) amount = str(parsed.get("amount") or amount) pay_sub_type = str(parsed.get("paySubType") or pay_sub_type) quote_username = str(parsed.get("quoteUsername") or quote_username) @@ -1966,9 +2111,12 @@ def _row_to_search_hit( "content": content_text, "title": title, "url": url, + "linkType": link_type, + "linkStyle": link_style, "quoteUsername": quote_username, "quoteTitle": quote_title, "quoteContent": quote_content, + "quoteThumbUrl": quote_thumb_url, "amount": amount, "paySubType": pay_sub_type, "transferStatus": transfer_status, diff --git a/src/wechat_decrypt_tool/routers/chat.py b/src/wechat_decrypt_tool/routers/chat.py index 9b3de1d..278b229 100644 --- a/src/wechat_decrypt_tool/routers/chat.py +++ b/src/wechat_decrypt_tool/routers/chat.py @@ -92,6 +92,11 @@ _REALTIME_SYNC_LOCKS: dict[tuple[str, str], threading.Lock] = {} _REALTIME_SYNC_ALL_LOCKS: dict[str, threading.Lock] = {} +def _is_hex_md5(value: Any) -> bool: + s = str(value or "").strip().lower() + return len(s) == 32 and all(c in "0123456789abcdef" for c in s) + + def _avatar_url_unified( *, account_dir: Path, @@ -787,6 +792,82 @@ def _load_session_last_message_times(conn: sqlite3.Connection, usernames: list[s return out +def _session_row_get(row: Any, key: str, default: Any = None) -> Any: + try: + if isinstance(row, sqlite3.Row): + return row[key] + except Exception: + return default + try: + return row.get(key, default) + except Exception: + return default + + +def _contact_flag_is_top(flag_value: Any) -> bool: + try: + flag_int = int(flag_value) + except Exception: + return False + if flag_int < 0: + flag_int &= (1 << 64) - 1 + return bool((flag_int >> 11) & 1) + + +def _load_contact_top_flags(contact_db_path: Path, usernames: list[str]) -> dict[str, bool]: + uniq = list(dict.fromkeys([str(u or "").strip() for u in usernames if str(u or "").strip()])) + if not uniq: + return {} + if not contact_db_path.exists(): + return {} + + out: dict[str, bool] = {} + conn = sqlite3.connect(str(contact_db_path)) + conn.row_factory = sqlite3.Row + try: + def has_flag_column(table: str) -> bool: + try: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + except Exception: + return False + cols: set[str] = set() + for r in rows: + try: + cols.add(str(r["name"] if isinstance(r, sqlite3.Row) else r[1]).strip().lower()) + except Exception: + continue + return ("username" in cols) and ("flag" in cols) + + chunk_size = 900 + for table in ("contact", "stranger"): + if not has_flag_column(table): + continue + + for i in range(0, len(uniq), chunk_size): + chunk = uniq[i : i + chunk_size] + placeholders = ",".join(["?"] * len(chunk)) + try: + rows = conn.execute( + f"SELECT username, flag FROM {table} WHERE username IN ({placeholders})", + chunk, + ).fetchall() + except Exception: + continue + + for r in rows: + username = str(_session_row_get(r, "username", "") or "").strip() + if not username: + continue + is_top = _contact_flag_is_top(_session_row_get(r, "flag", 0)) + if is_top: + out[username] = True + else: + out.setdefault(username, False) + return out + finally: + conn.close() + + @router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)") def sync_chat_realtime_messages( request: Request, @@ -2251,7 +2332,7 @@ def _append_full_messages_from_rows( if is_group and sender_prefix and (not sender_username): sender_username = sender_prefix - if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): + if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')): xml_sender = _extract_sender_from_group_xml(raw_text) if xml_sender: sender_username = xml_sender @@ -2287,6 +2368,9 @@ def _append_full_messages_from_rows( quote_username = "" quote_title = "" quote_content = "" + quote_thumb_url = "" + link_type = "" + link_style = "" quote_server_id = "" quote_type = "" quote_voice_length = "" @@ -2313,6 +2397,9 @@ def _append_full_messages_from_rows( record_item = str(parsed.get("recordItem") or "") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -2356,6 +2443,9 @@ def _append_full_messages_from_rows( content_text = str(parsed.get("content") or "[引用消息]") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -2468,6 +2558,20 @@ def _append_full_messages_from_rows( local_id=local_id, create_time=create_time, ) + + # Some WeChat builds store the on-disk thumbnail basename (32-hex) in packed_info_data (protobuf), + # while the message XML only carries a long cdnthumburl file_id. Prefer packed_info_data when present. + if not _is_hex_md5(video_thumb_md5): + try: + packed_val = r["packed_info_data"] + except Exception: + try: + packed_val = r.get("packed_info_data") # type: ignore[attr-defined] + except Exception: + packed_val = None + packed_md5 = _extract_md5_from_packed_info(packed_val) + if packed_md5: + video_thumb_md5 = packed_md5 content_text = "[视频]" elif local_type == 47: render_type = "emoji" @@ -2527,6 +2631,9 @@ def _append_full_messages_from_rows( record_item = str(parsed.get("recordItem") or record_item) quote_title = str(parsed.get("quoteTitle") or quote_title) quote_content = str(parsed.get("quoteContent") or quote_content) + quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url) + link_type = str(parsed.get("linkType") or link_type) + link_style = str(parsed.get("linkStyle") or link_style) amount = str(parsed.get("amount") or amount) cover_url = str(parsed.get("coverUrl") or cover_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url) @@ -2578,6 +2685,8 @@ def _append_full_messages_from_rows( "content": content_text, "title": title, "url": url, + "linkType": link_type, + "linkStyle": link_style, "from": from_name, "fromUsername": from_username, "recordItem": record_item, @@ -2601,6 +2710,7 @@ def _append_full_messages_from_rows( "quoteVoiceLength": str(quote_voice_length).strip(), "quoteTitle": quote_title, "quoteContent": quote_content, + "quoteThumbUrl": quote_thumb_url, "amount": amount, "coverUrl": cover_url, "fileSize": file_size, @@ -2619,6 +2729,111 @@ def _append_full_messages_from_rows( pass +def _postprocess_transfer_messages(merged: list[dict[str, Any]]) -> None: + # 后处理:关联转账消息的最终状态 + # 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配 + # paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期 + # + # Windows 微信在部分场景会为同一笔转账记录两条消息: + # - paysubtype=1/8:发起/待收款(这里回填为“已被接收”) + # - paysubtype=3:收款确认(展示为“已收款”) + # + # 这两条消息的 isSent 并不能稳定表示“付款方/收款方视角”,因此这里以 transferId 关联结果为准: + # - 将原始转账消息(1/8)回填为“已被接收” + # - 若同一 transferId 同时存在原始消息与 paysubtype=3 消息,则将 paysubtype=3 的那条校正为“已收款” + + returned_transfer_ids: set[str] = set() # 退还状态的 transferId + received_transfer_ids: set[str] = set() # 已收款状态的 transferId + returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配 + received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配 + pending_transfer_ids: set[str] = set() # (paysubtype=1/8) 的 transferId,用于识别“收款确认”消息 + + for m in merged: + if m.get("renderType") != "transfer": + continue + + pst = str(m.get("paySubType") or "") + tid = str(m.get("transferId") or "").strip() + amt = str(m.get("amount") or "") + ts = int(m.get("createTime") or 0) + + if tid and pst in ("1", "8"): + pending_transfer_ids.add(tid) + + if pst in ("4", "9"): # 退还状态 + if tid: + returned_transfer_ids.add(tid) + if amt: + returned_amounts_with_time.append((amt, ts)) + elif pst == "3": # 已收款状态 + if tid: + received_transfer_ids.add(tid) + if amt: + received_amounts_with_time.append((amt, ts)) + + backfilled_message_ids: set[str] = set() + + for m in merged: + if m.get("renderType") != "transfer": + continue + + pst = str(m.get("paySubType") or "") + if pst not in ("1", "8"): + continue + + tid = str(m.get("transferId") or "").strip() + amt = str(m.get("amount") or "") + ts = int(m.get("createTime") or 0) + + should_mark_returned = False + should_mark_received = False + + # 策略1:精确 transferId 匹配 + if tid: + if tid in returned_transfer_ids: + should_mark_returned = True + elif tid in received_transfer_ids: + should_mark_received = True + + # 策略2:回退到金额+时间窗口匹配(24小时内同金额) + if not should_mark_returned and not should_mark_received and amt: + for ret_amt, ret_ts in returned_amounts_with_time: + if ret_amt == amt and abs(ret_ts - ts) <= 86400: + should_mark_returned = True + break + if not should_mark_returned: + for rec_amt, rec_ts in received_amounts_with_time: + if rec_amt == amt and abs(rec_ts - ts) <= 86400: + should_mark_received = True + break + + if should_mark_returned: + m["paySubType"] = "9" + m["transferStatus"] = "已被退还" + elif should_mark_received: + m["paySubType"] = "3" + m["transferStatus"] = "已被接收" + mid = str(m.get("id") or "").strip() + if mid: + backfilled_message_ids.add(mid) + + # 修正收款确认消息:当同一 transferId 同时存在原始转账消息(1/8)与收款消息(3)时, + # paysubtype=3 的那条通常是收款确认消息,状态文案应为“已收款”。 + for m in merged: + if m.get("renderType") != "transfer": + continue + pst = str(m.get("paySubType") or "") + if pst != "3": + continue + tid = str(m.get("transferId") or "").strip() + if not tid or tid not in pending_transfer_ids: + continue + mid = str(m.get("id") or "").strip() + if mid and mid in backfilled_message_ids: + continue + m["transferStatus"] = "已收款" + + def _postprocess_full_messages( *, merged: list[dict[str, Any]], @@ -2631,75 +2846,7 @@ def _postprocess_full_messages( contact_db_path: Path, head_image_db_path: Path, ) -> None: - # 后处理:关联转账消息的最终状态 - # 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配 - # paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期 - - # 收集已退还和已收款的转账ID和金额 - returned_transfer_ids: set[str] = set() # 退还状态的 transferId - received_transfer_ids: set[str] = set() # 已收款状态的 transferId - returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配 - received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配 - - for m in merged: - if m.get("renderType") == "transfer": - pst = str(m.get("paySubType") or "") - tid = str(m.get("transferId") or "").strip() - amt = str(m.get("amount") or "") - ts = int(m.get("createTime") or 0) - - if pst in ("4", "9"): # 退还状态 - if tid: - returned_transfer_ids.add(tid) - if amt: - returned_amounts_with_time.append((amt, ts)) - elif pst == "3": # 已收款状态 - if tid: - received_transfer_ids.add(tid) - if amt: - received_amounts_with_time.append((amt, ts)) - - # 更新原始转账消息的状态 - for m in merged: - if m.get("renderType") == "transfer": - pst = str(m.get("paySubType") or "") - # 只更新未确定状态的原始转账消息(paysubtype=1 或 8) - if pst in ("1", "8"): - tid = str(m.get("transferId") or "").strip() - amt = str(m.get("amount") or "") - ts = int(m.get("createTime") or 0) - - # 优先检查退还状态(退还优先于收款) - should_mark_returned = False - should_mark_received = False - - # 策略1:精确 transferId 匹配 - if tid: - if tid in returned_transfer_ids: - should_mark_returned = True - elif tid in received_transfer_ids: - should_mark_received = True - - # 策略2:回退到金额+时间窗口匹配(24小时内同金额) - if not should_mark_returned and not should_mark_received and amt: - for ret_amt, ret_ts in returned_amounts_with_time: - if ret_amt == amt and abs(ret_ts - ts) <= 86400: - should_mark_returned = True - break - if not should_mark_returned: - for rec_amt, rec_ts in received_amounts_with_time: - if rec_amt == amt and abs(rec_ts - ts) <= 86400: - should_mark_received = True - break - - if should_mark_returned: - m["paySubType"] = "9" - m["transferStatus"] = "已被退还" - elif should_mark_received: - m["paySubType"] = "3" - # 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收" - is_sent = m.get("isSent", False) - m["transferStatus"] = "已收款" if is_sent else "已被接收" + _postprocess_transfer_messages(merged) # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername). # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar. @@ -3074,20 +3221,45 @@ def list_chat_sessions( finally: sconn.close() - filtered: list[sqlite3.Row] = [] - usernames: list[str] = [] + filtered: list[Any] = [] for r in rows: - username = r["username"] or "" + username = _session_row_get(r, "username", "") or "" if not username: continue - if not include_hidden and int(r["is_hidden"] or 0) == 1: + if not include_hidden and int((_session_row_get(r, "is_hidden", 0) or 0)) == 1: continue if not _should_keep_session(username, include_official=include_official): continue filtered.append(r) - usernames.append(username) - if len(filtered) >= int(limit): - break + + raw_usernames = [str(_session_row_get(r, "username", "") or "").strip() for r in filtered] + top_flags = _load_contact_top_flags(contact_db_path, raw_usernames) + + def _to_int(v: Any) -> int: + try: + return int(v or 0) + except Exception: + return 0 + + def _session_sort_key(row: Any) -> tuple[int, int, int]: + username = str(_session_row_get(row, "username", "") or "").strip() + sort_ts = _to_int(_session_row_get(row, "sort_timestamp", 0)) + last_ts = _to_int(_session_row_get(row, "last_timestamp", 0)) + return ( + 1 if bool(top_flags.get(username, False)) else 0, + sort_ts, + last_ts, + ) + + filtered.sort(key=_session_sort_key, reverse=True) + if len(filtered) > int(limit): + filtered = filtered[: int(limit)] + + usernames: list[str] = [] + for r in filtered: + username = str(_session_row_get(r, "username", "") or "").strip() + if username: + usernames.append(username) contact_rows = _load_contact_rows(contact_db_path, usernames) local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames) @@ -3121,12 +3293,20 @@ def list_chat_sessions( need_display = list(dict.fromkeys(need_display)) need_avatar = list(dict.fromkeys(need_avatar)) if need_display or need_avatar: - wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir) - with wcdb_conn.lock: - if need_display: - wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display) - if need_avatar: - wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar) + wcdb_conn = rt_conn + if wcdb_conn is None: + status = WCDB_REALTIME.get_status(account_dir) + can_connect = bool(status.get("dll_present")) and bool(status.get("key_present")) and bool( + status.get("session_db_path") + ) + if can_connect: + wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir) + if wcdb_conn is not None: + with wcdb_conn.lock: + if need_display: + wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display) + if need_avatar: + wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar) except Exception: wcdb_display_names = {} wcdb_avatar_urls = {} @@ -3296,6 +3476,7 @@ def list_chat_sessions( "lastMessageTime": last_time, "unreadCount": int(r["unread_count"] or 0), "isGroup": bool(username.endswith("@chatroom")), + "isTop": bool(top_flags.get(str(username or "").strip(), False)), } ) @@ -3439,7 +3620,7 @@ def _collect_chat_messages( if is_group and sender_prefix and (not sender_username): sender_username = sender_prefix - if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): + if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')): xml_sender = _extract_sender_from_group_xml(raw_text) if xml_sender: sender_username = xml_sender @@ -3472,6 +3653,9 @@ def _collect_chat_messages( quote_username = "" quote_title = "" quote_content = "" + quote_thumb_url = "" + link_type = "" + link_style = "" quote_server_id = "" quote_type = "" quote_voice_length = "" @@ -3498,6 +3682,9 @@ def _collect_chat_messages( record_item = str(parsed.get("recordItem") or "") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -3541,6 +3728,9 @@ def _collect_chat_messages( content_text = str(parsed.get("content") or "[引用消息]") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -3640,6 +3830,11 @@ def _collect_chat_messages( local_id=local_id, create_time=create_time, ) + + if not _is_hex_md5(video_thumb_md5): + packed_md5 = _extract_md5_from_packed_info(r["packed_info_data"]) + if packed_md5: + video_thumb_md5 = packed_md5 content_text = "[视频]" elif local_type == 47: render_type = "emoji" @@ -3701,6 +3896,9 @@ def _collect_chat_messages( record_item = str(parsed.get("recordItem") or record_item) quote_title = str(parsed.get("quoteTitle") or quote_title) quote_content = str(parsed.get("quoteContent") or quote_content) + quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url) + link_type = str(parsed.get("linkType") or link_type) + link_style = str(parsed.get("linkStyle") or link_style) amount = str(parsed.get("amount") or amount) cover_url = str(parsed.get("coverUrl") or cover_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url) @@ -3758,6 +3956,8 @@ def _collect_chat_messages( "content": content_text, "title": title, "url": url, + "linkType": link_type, + "linkStyle": link_style, "from": from_name, "fromUsername": from_username, "recordItem": record_item, @@ -3781,6 +3981,7 @@ def _collect_chat_messages( "quoteVoiceLength": str(quote_voice_length).strip(), "quoteTitle": quote_title, "quoteContent": quote_content, + "quoteThumbUrl": quote_thumb_url, "amount": amount, "coverUrl": cover_url, "fileSize": file_size, @@ -4139,7 +4340,7 @@ def list_chat_messages( if is_group and sender_prefix: sender_username = sender_prefix - if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): + if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')): xml_sender = _extract_sender_from_group_xml(raw_text) if xml_sender: sender_username = xml_sender @@ -4175,6 +4376,9 @@ def list_chat_messages( quote_username = "" quote_title = "" quote_content = "" + quote_thumb_url = "" + link_type = "" + link_style = "" quote_server_id = "" quote_type = "" quote_voice_length = "" @@ -4201,6 +4405,9 @@ def list_chat_messages( record_item = str(parsed.get("recordItem") or "") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -4244,6 +4451,9 @@ def list_chat_messages( content_text = str(parsed.get("content") or "[引用消息]") quote_title = str(parsed.get("quoteTitle") or "") quote_content = str(parsed.get("quoteContent") or "") + quote_thumb_url = str(parsed.get("quoteThumbUrl") or "") + link_type = str(parsed.get("linkType") or "") + link_style = str(parsed.get("linkStyle") or "") quote_username = str(parsed.get("quoteUsername") or "") quote_server_id = str(parsed.get("quoteServerId") or "") quote_type = str(parsed.get("quoteType") or "") @@ -4400,6 +4610,9 @@ def list_chat_messages( record_item = str(parsed.get("recordItem") or record_item) quote_title = str(parsed.get("quoteTitle") or quote_title) quote_content = str(parsed.get("quoteContent") or quote_content) + quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url) + link_type = str(parsed.get("linkType") or link_type) + link_style = str(parsed.get("linkStyle") or link_style) amount = str(parsed.get("amount") or amount) cover_url = str(parsed.get("coverUrl") or cover_url) thumb_url = str(parsed.get("thumbUrl") or thumb_url) @@ -4450,6 +4663,8 @@ def list_chat_messages( "content": content_text, "title": title, "url": url, + "linkType": link_type, + "linkStyle": link_style, "from": from_name, "fromUsername": from_username, "recordItem": record_item, @@ -4473,6 +4688,7 @@ def list_chat_messages( "quoteVoiceLength": str(quote_voice_length).strip(), "quoteTitle": quote_title, "quoteContent": quote_content, + "quoteThumbUrl": quote_thumb_url, "amount": amount, "coverUrl": cover_url, "fileSize": file_size, @@ -4509,81 +4725,38 @@ def list_chat_messages( deduped.append(m) merged = deduped - # 后处理:关联转账消息的最终状态 - # 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配 - # paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期 + _postprocess_transfer_messages(merged) - # 收集已退还和已收款的转账ID和金额 - returned_transfer_ids: set[str] = set() # 退还状态的 transferId - received_transfer_ids: set[str] = set() # 已收款状态的 transferId - returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配 - received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配 + def sort_key(m: dict[str, Any]) -> tuple[int, int, int]: + sseq = int(m.get("sortSeq") or 0) + cts = int(m.get("createTime") or 0) + lid = int(m.get("localId") or 0) + return (cts, sseq, lid) - for m in merged: - if m.get("renderType") == "transfer": - pst = str(m.get("paySubType") or "") - tid = str(m.get("transferId") or "").strip() - amt = str(m.get("amount") or "") - ts = int(m.get("createTime") or 0) + merged.sort(key=sort_key, reverse=True) + has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit)))) + page = merged[int(offset) : int(offset) + int(limit)] + if want_asc: + page = list(reversed(page)) - if pst in ("4", "9"): # 退还状态 - if tid: - returned_transfer_ids.add(tid) - if amt: - returned_amounts_with_time.append((amt, ts)) - elif pst == "3": # 已收款状态 - if tid: - received_transfer_ids.add(tid) - if amt: - received_amounts_with_time.append((amt, ts)) + # Hot path optimization: only enrich the page we return. + if not page: + return { + "status": "success", + "account": account_dir.name, + "username": username, + "total": int(offset) + (1 if has_more_global else 0), + "hasMore": bool(has_more_global), + "messages": [], + } - # 更新原始转账消息的状态 - for m in merged: - if m.get("renderType") == "transfer": - pst = str(m.get("paySubType") or "") - # 只更新未确定状态的原始转账消息(paysubtype=1 或 8) - if pst in ("1", "8"): - tid = str(m.get("transferId") or "").strip() - amt = str(m.get("amount") or "") - ts = int(m.get("createTime") or 0) - - # 优先检查退还状态(退还优先于收款) - should_mark_returned = False - should_mark_received = False - - # 策略1:精确 transferId 匹配 - if tid: - if tid in returned_transfer_ids: - should_mark_returned = True - elif tid in received_transfer_ids: - should_mark_received = True - - # 策略2:回退到金额+时间窗口匹配(24小时内同金额) - if not should_mark_returned and not should_mark_received and amt: - for ret_amt, ret_ts in returned_amounts_with_time: - if ret_amt == amt and abs(ret_ts - ts) <= 86400: - should_mark_returned = True - break - if not should_mark_returned: - for rec_amt, rec_ts in received_amounts_with_time: - if rec_amt == amt and abs(rec_ts - ts) <= 86400: - should_mark_received = True - break - - if should_mark_returned: - m["paySubType"] = "9" - m["transferStatus"] = "已被退还" - elif should_mark_received: - m["paySubType"] = "3" - # 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收" - is_sent = m.get("isSent", False) - m["transferStatus"] = "已收款" if is_sent else "已被接收" + messages_window = page # Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername). # Recover `fromUsername` via contact.db so the frontend can render the publisher avatar. missing_from_names = [ str(m.get("from") or "").strip() - for m in merged + for m in messages_window if str(m.get("renderType") or "").strip() == "link" and str(m.get("from") or "").strip() and not str(m.get("fromUsername") or "").strip() @@ -4591,7 +4764,7 @@ def list_chat_messages( if missing_from_names: name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names) if name_to_username: - for m in merged: + for m in messages_window: if str(m.get("fromUsername") or "").strip(): continue if str(m.get("renderType") or "").strip() != "link": @@ -4600,10 +4773,33 @@ def list_chat_messages( if fn and fn in name_to_username: m["fromUsername"] = name_to_username[fn] - from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged] + pat_usernames_in_page: set[str] = set() + for m in messages_window: + if int(m.get("type") or 0) != 266287972401: + continue + raw = str(m.get("_rawText") or "") + if not raw: + continue + template = _extract_xml_tag_text(raw, "template") + if not template: + continue + pat_usernames_in_page.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)}) + + from_usernames = [str(m.get("fromUsername") or "").strip() for m in messages_window] + sender_usernames_in_page = [str(m.get("senderUsername") or "").strip() for m in messages_window] + quote_usernames_in_page = [str(m.get("quoteUsername") or "").strip() for m in messages_window] uniq_senders = list( dict.fromkeys( - [u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u] + [ + u + for u in ( + sender_usernames_in_page + + list(pat_usernames_in_page) + + quote_usernames_in_page + + from_usernames + ) + if u + ] ) ) sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders) @@ -4645,7 +4841,7 @@ def list_chat_messages( sender_usernames=uniq_senders, ) - for m in merged: + for m in messages_window: # If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name. if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip(): fu = str(m.get("fromUsername") or "").strip() @@ -4789,18 +4985,6 @@ def list_chat_messages( if "_rawText" in m: m.pop("_rawText", None) - def sort_key(m: dict[str, Any]) -> tuple[int, int, int]: - sseq = int(m.get("sortSeq") or 0) - cts = int(m.get("createTime") or 0) - lid = int(m.get("localId") or 0) - return (cts, sseq, lid) - - merged.sort(key=sort_key, reverse=True) - has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit)))) - page = merged[int(offset) : int(offset) + int(limit)] - if want_asc: - page = list(reversed(page)) - return { "status": "success", "account": account_dir.name, @@ -5762,10 +5946,21 @@ async def get_chat_messages_around( my_rowid = None quoted_table = _quote_ident(table_name) + has_packed_info_data = False + try: + cols = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall() + has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols) + except Exception: + has_packed_info_data = False + packed_select = ( + "m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, " + ) sql_anchor_with_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, n.user_name AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "n.user_name AS sender_username " f"FROM {quoted_table} m " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " "WHERE m.local_id = ? " @@ -5774,7 +5969,9 @@ async def get_chat_messages_around( sql_anchor_no_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, '' AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "'' AS sender_username " f"FROM {quoted_table} m " "WHERE m.local_id = ? " "LIMIT 1" @@ -5811,7 +6008,9 @@ async def get_chat_messages_around( sql_before_with_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, n.user_name AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "n.user_name AS sender_username " f"FROM {quoted_table} m " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " f"{where_before} " @@ -5821,7 +6020,9 @@ async def get_chat_messages_around( sql_before_no_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, '' AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "'' AS sender_username " f"FROM {quoted_table} m " f"{where_before} " "ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC " @@ -5831,7 +6032,9 @@ async def get_chat_messages_around( sql_after_with_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, n.user_name AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "n.user_name AS sender_username " f"FROM {quoted_table} m " "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " f"{where_after} " @@ -5841,7 +6044,9 @@ async def get_chat_messages_around( sql_after_no_join = ( "SELECT " "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " - "m.message_content, m.compress_content, '' AS sender_username " + "m.message_content, m.compress_content, " + + packed_select + + "'' AS sender_username " f"FROM {quoted_table} m " f"{where_after} " "ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC " diff --git a/tests/test_chat_realtime_video_thumb_md5_from_packed_info.py b/tests/test_chat_realtime_video_thumb_md5_from_packed_info.py new file mode 100644 index 0000000..d35a547 --- /dev/null +++ b/tests/test_chat_realtime_video_thumb_md5_from_packed_info.py @@ -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 = ( + '\n' + "\n" + f' \n' + "\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() + diff --git a/tests/test_chat_sessions_pinning.py b/tests/test_chat_sessions_pinning.py new file mode 100644 index 0000000..f2da9f6 --- /dev/null +++ b/tests/test_chat_sessions_pinning.py @@ -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() + diff --git a/tests/test_group_xml_sender_extraction.py b/tests/test_group_xml_sender_extraction.py new file mode 100644 index 0000000..c719bdc --- /dev/null +++ b/tests/test_group_xml_sender_extraction.py @@ -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 = ( + '57' + 'quoted_user@chatroom' + 'actual_sender@chatroom' + ) + self.assertEqual(_extract_sender_from_group_xml(xml_text), "actual_sender@chatroom") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_parse_app_message.py b/tests/test_parse_app_message.py new file mode 100644 index 0000000..94f6336 --- /dev/null +++ b/tests/test_parse_app_message.py @@ -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 = ( + '' + '一松一紧57' + '00' + '' + '0' + '' + '' + '571173057991425172913' + '44372432598@chatroom44372432598@chatroom' + '' + '那里紧?哪里张?' + '5700' + '' + '0' + '' + ']]>' + '' + ) + + 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 = ( + '' + '回复57' + '57' + '' + ) + + 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 = ( + '' + '这种傻逼公众号怎么还在看57' + '49' + '' + '为自己的美丽漂亮善良知性发声😊' + '5https://mp.weixin.qq.com/s/example' + 'https://mmbiz.qpic.cn/some-thumb.jpg' + ']]>' + ) + + 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 = ( + '' + '为自己的美丽漂亮善良知性发声😊' + '#日常穿搭灵感 #白色蕾丝裙穿搭 #知性美女' + '5' + 'http://mp.weixin.qq.com/s?__biz=xx&mid=1' + 'http://mmbiz.qpic.cn/abc/640?wx_fmt=jpeg' + 'gh_0cef8eaa987d' + '草莓不甜芒果甜' + '' + ) + + 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 = ( + '' + '这个年龄有点大啊57' + '5' + '\n' + '谁说冬天不能穿裙子?5' + 'https://mmbiz.qpic.cn/some-thumb2.jpg' + 'https://mp.weixin.qq.com/s/example2' + ']]>' + ) + + 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() diff --git a/tests/test_transfer_postprocess.py b/tests/test_transfer_postprocess.py new file mode 100644 index 0000000..965ceea --- /dev/null +++ b/tests/test_transfer_postprocess.py @@ -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() + diff --git a/tests/test_transfer_status_text.py b/tests/test_transfer_status_text.py new file mode 100644 index 0000000..477ac03 --- /dev/null +++ b/tests/test_transfer_status_text.py @@ -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()