mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
9 Commits
+6
-1
@@ -57,7 +57,12 @@ const contentClass = computed(() =>
|
||||
: 'flex-1 overflow-auto min-h-0'
|
||||
)
|
||||
|
||||
const showSidebar = computed(() => !String(route.path || '').startsWith('/wrapped'))
|
||||
const showSidebar = computed(() => {
|
||||
const path = String(route.path || '')
|
||||
if (path === '/') return false
|
||||
if (path === '/decrypt' || path === '/detection-result' || path === '/decrypt-result') return false
|
||||
return !(path === '/wrapped' || path.startsWith('/wrapped/'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,793 @@
|
||||
<template>
|
||||
<div ref="cardRoot" class="h-full w-full">
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
|
||||
<template #narrative>
|
||||
<div class="mt-1 wrapped-body text-sm sm:text-base text-[#6F6F6F] leading-relaxed">
|
||||
<p class="whitespace-normal">
|
||||
<template v-for="(seg, idx) in narrativeSegments" :key="`n-${idx}`">
|
||||
<img
|
||||
v-if="seg.type === 'emoji'"
|
||||
:src="seg.src"
|
||||
class="inline-block align-[-0.18em] rounded-[3px] wx-inline-emoji"
|
||||
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||
:alt="seg.alt || 'emoji'"
|
||||
/>
|
||||
<img
|
||||
v-else-if="seg.type === 'sticker'"
|
||||
:src="seg.src"
|
||||
class="inline-block align-[-0.16em] rounded-[4px] wx-inline-emoji"
|
||||
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||
:alt="seg.alt || 'sticker'"
|
||||
/>
|
||||
<span
|
||||
v-else-if="seg.type === 'num'"
|
||||
class="wrapped-number text-[#07C160] font-semibold"
|
||||
>
|
||||
{{ seg.content }}
|
||||
</span>
|
||||
<span v-else>{{ seg.content }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full -mt-1 sm:-mt-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2">
|
||||
<div class="lg:col-span-7 space-y-2 sm:space-y-2.5">
|
||||
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">高频表情卡堆(Vue Bits)</div>
|
||||
<div v-if="stackCardsData.length > 0" class="mt-2">
|
||||
<div class="relative h-[9.4rem] sm:h-[9.8rem] rounded-xl overflow-visible">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Stack
|
||||
:randomRotation="true"
|
||||
:sensitivity="180"
|
||||
:sendToBackOnClick="false"
|
||||
:cardDimensions="stackCardDimensions"
|
||||
:cardsData="stackCardsData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-col items-center justify-center text-center">
|
||||
<div class="wrapped-number text-base text-[#07C160] font-semibold leading-tight">
|
||||
{{ formatInt(Number(stackTopCount || 0)) }} 次
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-0.5 inline-flex items-center gap-1.5 rounded-md bg-[#00000008] px-1.5 py-1 max-w-full"
|
||||
:title="heroStickerOwnerName ? `常发送给 ${heroStickerOwnerName}` : '常发送给:未知'"
|
||||
>
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
|
||||
:src="heroStickerOwnerAvatarUrl"
|
||||
class="w-full h-full object-cover"
|
||||
alt="avatar"
|
||||
@error="avatarOk.topStickerOwner = false"
|
||||
/>
|
||||
<span v-else class="wrapped-number text-[10px] text-[#00000066]">
|
||||
{{ avatarFallback(heroStickerOwnerName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
|
||||
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 wrapped-label text-[10px] text-[#00000055]">拖动表情卡片翻一翻</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 wrapped-body text-xs text-[#00000055]">
|
||||
暂无可展示的高频表情图片。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">Emoji Top(小黄脸 + Unicode)</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#07C160]/10 text-[#07C160] border-[#07C160]/25">
|
||||
小黄脸
|
||||
</span>
|
||||
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#0EA5E9]/10 text-[#0EA5E9] border-[#0EA5E9]/25">
|
||||
Unicode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="emojiBubbleRows.length > 0" class="mt-2">
|
||||
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-2.5 place-items-end">
|
||||
<div
|
||||
v-for="row in emojiBubbleRows"
|
||||
:key="row.id"
|
||||
class="min-w-0 flex flex-col items-center gap-1"
|
||||
:title="`${row.label} · ${formatInt(row.count)}次`"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full shadow-sm"
|
||||
:class="row.kind === 'wechat' ? 'bg-[#07C160]/14' : 'bg-[#0EA5E9]/12'"
|
||||
:style="{ width: `${row.size}px`, height: `${row.size}px` }"
|
||||
>
|
||||
<img
|
||||
v-if="row.kind === 'wechat' && row.assetPath && emojiAssetOk[row.key] !== false"
|
||||
:src="resolveEmojiAsset(row.assetPath)"
|
||||
class="w-3/4 h-3/4 object-contain"
|
||||
alt="emoji"
|
||||
@error="emojiAssetOk[row.key] = false"
|
||||
/>
|
||||
<span v-else class="text-xl leading-none">
|
||||
{{ row.kind === 'unicode' ? row.label : '🙂' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="wrapped-number text-[10px] font-semibold leading-none"
|
||||
:class="row.kind === 'wechat' ? 'text-[#07C160]' : 'text-[#0EA5E9]'"
|
||||
>
|
||||
{{ formatInt(row.count) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 wrapped-body text-xs text-[#00000066]">
|
||||
今年没有统计到可识别的 Emoji(小黄脸/Unicode)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-5 h-full min-h-[20rem] sm:min-h-[21.5rem] rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3 relative overflow-hidden">
|
||||
<div class="relative z-[1] h-full flex flex-col">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">斗图热力时段</div>
|
||||
<div class="mt-1 wrapped-body text-sm text-[#000000e6]">
|
||||
<template v-if="peakHour !== null && peakWeekdayName">
|
||||
高峰在
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ peakWeekdayName }} {{ peakHour }}:00</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
今年没有明显的斗图高峰时段
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 h-16 sm:h-20 flex items-end gap-[2px]">
|
||||
<div
|
||||
v-for="item in hourBars"
|
||||
:key="`hour-${item.hour}`"
|
||||
class="flex-1 min-w-0 rounded-sm bg-[#07C160]/20"
|
||||
:style="{ height: `${item.heightPct}%` }"
|
||||
:title="`${item.hour}:00 · ${formatInt(item.count)}次`"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center justify-between wrapped-label text-[10px] text-[#00000055]">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-[#EDEDED] bg-white/60 p-2.5 flex-1 flex flex-col">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">表情新鲜度</div>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2.5">
|
||||
<div class="rounded-lg bg-[#07C160]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">年度新解锁</div>
|
||||
<div class="mt-1.5 wrapped-number text-lg text-[#07C160] font-semibold leading-tight">
|
||||
{{ formatInt(newStickerCountThisYear) }}
|
||||
</div>
|
||||
<div class="wrapped-label text-[11px] text-[#00000055]">
|
||||
占类型 {{ newStickerSharePct }}%
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="newStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||
<div
|
||||
v-for="item in newStickerDecorDisplayItems"
|
||||
:key="`new-chip-${item.id}`"
|
||||
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||
>
|
||||
<img
|
||||
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||
:src="item.src"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
@error="stickerDecorOk[item.id] = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-[#0EA5E9]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-label text-[11px] text-[#00000066]">回温表情</div>
|
||||
<div class="mt-1.5 wrapped-number text-lg text-[#0EA5E9] font-semibold leading-tight">
|
||||
{{ formatInt(revivedStickerCount) }}
|
||||
</div>
|
||||
<div class="wrapped-label text-[11px] text-[#00000055] leading-tight">
|
||||
间隔≥{{ formatInt(revivedMinGapDays) }}天,最长 {{ formatInt(revivedMaxGapDays) }} 天
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="revivedStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||
<div
|
||||
v-for="item in revivedStickerDecorDisplayItems"
|
||||
:key="`rev-chip-${item.id}`"
|
||||
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||
>
|
||||
<img
|
||||
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||
:src="item.src"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
@error="stickerDecorOk[item.id] = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 wrapped-body text-[11px] text-[#00000066]">
|
||||
今年共用过 <span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(uniqueStickerTypeCount) }}</span> 种表情,
|
||||
回温占比 <span class="wrapped-number text-[#0EA5E9] font-semibold">{{ revivedStickerSharePct }}</span>%。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="cursorFxEnabled && isCardVisible && cursorTrails.length > 0" class="emoji-cursor-layer" aria-hidden="true">
|
||||
<img
|
||||
v-for="item in cursorTrails"
|
||||
:key="item.id"
|
||||
class="emoji-cursor-item"
|
||||
:style="{
|
||||
left: `${item.x}px`,
|
||||
top: `${item.y}px`,
|
||||
width: `${item.size}px`,
|
||||
height: `${item.size}px`,
|
||||
'--drift-x': `${item.driftX}px`,
|
||||
'--drift-y': `${item.driftY}px`
|
||||
}"
|
||||
:src="item.src"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
|
||||
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(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
|
||||
}
|
||||
if (opts.backend || raw.startsWith('/api/')) {
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
}
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const resolveEmojiAsset = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
if (raw.startsWith('/wxemoji/')) return raw
|
||||
if (raw.startsWith('/')) return raw
|
||||
return `/wxemoji/${raw}`
|
||||
}
|
||||
|
||||
const resolveStickerUrl = (st) => {
|
||||
const remote = resolveMediaUrl(st?.emojiUrl, { backend: true })
|
||||
if (remote) return remote
|
||||
const asset = resolveEmojiAsset(st?.emojiAssetPath)
|
||||
return asset || ''
|
||||
}
|
||||
|
||||
const splitTextByNumbers = (text) => {
|
||||
const raw = String(text || '')
|
||||
if (!raw) return []
|
||||
const parts = raw.split(/(\d[\d,.]*)/g)
|
||||
const out = []
|
||||
for (const p of parts) {
|
||||
if (!p) continue
|
||||
if (/^\d[\d,.]*$/.test(p)) out.push({ type: 'num', content: p })
|
||||
else out.push({ type: 'text', content: p })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const appendParsedText = (segments, text) => {
|
||||
const parsed = parseTextWithEmoji(String(text || ''))
|
||||
for (const seg of parsed) {
|
||||
if (seg?.type === 'emoji' && seg.emojiSrc) {
|
||||
segments.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(seg.emojiSrc),
|
||||
alt: seg.content || 'emoji',
|
||||
sizeEm: 1.12
|
||||
})
|
||||
continue
|
||||
}
|
||||
const inner = splitTextByNumbers(seg?.content || '')
|
||||
for (const x of inner) segments.push(x)
|
||||
}
|
||||
}
|
||||
|
||||
const sentStickerCount = computed(() => Number(props.card?.data?.sentStickerCount || 0))
|
||||
const stickerActiveDays = computed(() => Number(props.card?.data?.stickerActiveDays || 0))
|
||||
const peakHour = computed(() => {
|
||||
const h = props.card?.data?.peakHour
|
||||
return Number.isFinite(Number(h)) ? Number(h) : null
|
||||
})
|
||||
const peakWeekdayName = computed(() => String(props.card?.data?.peakWeekdayName || '').trim())
|
||||
|
||||
const stickerPerActiveDayText = computed(() => {
|
||||
const v = Number(props.card?.data?.stickerPerActiveDay || 0)
|
||||
return Number.isFinite(v) ? v.toFixed(1) : '0.0'
|
||||
})
|
||||
const uniqueStickerTypeCount = computed(() => Number(props.card?.data?.uniqueStickerTypeCount || 0))
|
||||
const newStickerCountThisYear = computed(() => Number(props.card?.data?.newStickerCountThisYear || 0))
|
||||
const revivedStickerCount = computed(() => Number(props.card?.data?.revivedStickerCount || 0))
|
||||
const revivedMinGapDays = computed(() => Number(props.card?.data?.revivedMinGapDays || 60))
|
||||
const revivedMaxGapDays = computed(() => Number(props.card?.data?.revivedMaxGapDays || 0))
|
||||
const newStickerSharePct = computed(() => {
|
||||
const v = Number(props.card?.data?.newStickerShare)
|
||||
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((Number(newStickerCountThisYear.value || 0) / total) * 100)))
|
||||
})
|
||||
const revivedStickerSharePct = computed(() => {
|
||||
const v = Number(props.card?.data?.revivedStickerShare)
|
||||
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((Number(revivedStickerCount.value || 0) / total) * 100)))
|
||||
})
|
||||
const newStickerSamples = computed(() => {
|
||||
const arr = props.card?.data?.newStickerSamples
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const revivedStickerSamples = computed(() => {
|
||||
const arr = props.card?.data?.revivedStickerSamples
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const buildDecorStickerItems = (rows, prefix) => {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (let idx = 0; idx < rows.length; idx += 1) {
|
||||
const st = rows[idx] || {}
|
||||
const rawId = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || `${prefix}-${idx}`).trim()
|
||||
if (!rawId || seen.has(rawId)) continue
|
||||
seen.add(rawId)
|
||||
out.push({
|
||||
id: `${prefix}-${rawId}`,
|
||||
src: resolveStickerUrl(st),
|
||||
count: Math.max(0, Number(st?.count || 0)),
|
||||
gapDays: Math.max(0, Number(st?.gapDays || 0))
|
||||
})
|
||||
if (out.length >= 4) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const topStickers = computed(() => {
|
||||
const arr = props.card?.data?.topStickers
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const newStickerDecorItems = computed(() => buildDecorStickerItems(newStickerSamples.value, 'new'))
|
||||
const revivedStickerDecorItems = computed(() => buildDecorStickerItems(revivedStickerSamples.value, 'revived'))
|
||||
const topStickerDecorItems = computed(() => buildDecorStickerItems(topStickers.value, 'top'))
|
||||
const toRenderableDecor = (items) => items.filter((x) => String(x?.src || '').trim())
|
||||
const decorFallbackPool = computed(() => {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const item of [
|
||||
...toRenderableDecor(newStickerDecorItems.value),
|
||||
...toRenderableDecor(revivedStickerDecorItems.value),
|
||||
...toRenderableDecor(topStickerDecorItems.value)
|
||||
]) {
|
||||
const key = String(item?.src || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(item)
|
||||
if (out.length >= 6) break
|
||||
}
|
||||
return out
|
||||
})
|
||||
const newStickerDecorDisplayItems = computed(() => {
|
||||
const own = toRenderableDecor(newStickerDecorItems.value).slice(0, 3)
|
||||
if (own.length > 0) return own
|
||||
return decorFallbackPool.value.slice(0, 3)
|
||||
})
|
||||
const revivedStickerDecorDisplayItems = computed(() => {
|
||||
const own = toRenderableDecor(revivedStickerDecorItems.value).slice(0, 3)
|
||||
if (own.length > 0) return own
|
||||
const fallback = decorFallbackPool.value.slice(3, 6)
|
||||
return fallback.length > 0 ? fallback : decorFallbackPool.value.slice(0, 3)
|
||||
})
|
||||
|
||||
const heroSticker = computed(() => {
|
||||
const arr = topStickers.value
|
||||
return Array.isArray(arr) && arr.length > 0 ? arr[0] : null
|
||||
})
|
||||
|
||||
const heroStickerUrl = computed(() => resolveStickerUrl(heroSticker.value))
|
||||
const heroStickerOwnerName = computed(() => String(heroSticker.value?.sampleDisplayName || heroSticker.value?.sampleUsername || '').trim())
|
||||
const heroStickerOwnerAvatarUrl = computed(() => resolveMediaUrl(heroSticker.value?.sampleAvatarUrl, { backend: true }))
|
||||
|
||||
const stackCardDimensions = { width: 140, height: 140 }
|
||||
|
||||
const stackCardsData = computed(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const st of topStickers.value) {
|
||||
const key = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
const src = resolveStickerUrl(st)
|
||||
if (!src) continue
|
||||
seen.add(key)
|
||||
out.push({ id: key, img: src })
|
||||
if (out.length >= 4) break
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
return allWechatEmojiAssets.value.slice(0, 4).map((img, idx) => ({ id: `wx-${idx}`, img }))
|
||||
})
|
||||
|
||||
const stackTopCount = computed(() => Number(heroSticker.value?.count || 0))
|
||||
|
||||
const hourBars = computed(() => {
|
||||
const raw = Array.isArray(props.card?.data?.stickerHourCounts) ? props.card.data.stickerHourCounts : []
|
||||
const counts = Array.from({ length: 24 }, (_, i) => Math.max(0, Number(raw[i] || 0)))
|
||||
const maxV = Math.max(1, ...counts)
|
||||
return counts.map((count, hour) => ({
|
||||
hour,
|
||||
count,
|
||||
heightPct: Math.max(8, Math.round((count / maxV) * 100))
|
||||
}))
|
||||
})
|
||||
|
||||
const topTextEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topTextEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const topWechatEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topWechatEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
const topUnicodeEmojis = computed(() => {
|
||||
const arr = props.card?.data?.topUnicodeEmojis
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const smallWechatEmojiChips = computed(() => {
|
||||
if (topWechatEmojis.value.length > 0) {
|
||||
return topWechatEmojis.value.map((x) => ({
|
||||
key: String(x?.key || ''),
|
||||
count: Number(x?.count || 0),
|
||||
assetPath: String(x?.assetPath || '')
|
||||
}))
|
||||
}
|
||||
return topTextEmojis.value.map((x) => ({
|
||||
key: String(x?.key || ''),
|
||||
count: Number(x?.count || 0),
|
||||
assetPath: String(x?.assetPath || '')
|
||||
}))
|
||||
})
|
||||
|
||||
const emojiAssetOk = reactive({})
|
||||
watch(
|
||||
smallWechatEmojiChips,
|
||||
(arr) => {
|
||||
for (const k of Object.keys(emojiAssetOk)) delete emojiAssetOk[k]
|
||||
if (!Array.isArray(arr)) return
|
||||
for (const em of arr) {
|
||||
const key = String(em?.key || '').trim()
|
||||
if (key) emojiAssetOk[key] = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const emojiChartRows = computed(() => {
|
||||
const wechat = smallWechatEmojiChips.value
|
||||
.slice(0, 4)
|
||||
.map((x, idx) => ({
|
||||
id: `w-${String(x?.key || idx)}`,
|
||||
kind: 'wechat',
|
||||
key: String(x?.key || idx),
|
||||
label: String(x?.key || '').trim() || '微信表情',
|
||||
count: Math.max(0, Number(x?.count || 0)),
|
||||
assetPath: String(x?.assetPath || '').trim()
|
||||
}))
|
||||
|
||||
const unicode = topUnicodeEmojis.value
|
||||
.slice(0, 4)
|
||||
.map((x, idx) => ({
|
||||
id: `u-${String(x?.emoji || idx)}`,
|
||||
kind: 'unicode',
|
||||
key: String(x?.emoji || idx),
|
||||
label: String(x?.emoji || '').trim() || '😀',
|
||||
count: Math.max(0, Number(x?.count || 0)),
|
||||
assetPath: ''
|
||||
}))
|
||||
|
||||
const rows = [...wechat, ...unicode].filter((x) => x.count > 0 && x.label)
|
||||
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||
return rows
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
pct: Math.max(8, Math.round((x.count / maxV) * 100))
|
||||
}))
|
||||
})
|
||||
|
||||
const emojiBubbleRows = computed(() => {
|
||||
const rows = emojiChartRows.value
|
||||
if (!rows.length) return []
|
||||
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||
return rows.map((x) => {
|
||||
const ratio = Math.max(0, Math.min(1, x.count / maxV))
|
||||
const size = Math.round(32 + Math.sqrt(ratio) * 36)
|
||||
return { ...x, size: Math.max(28, Math.min(72, size)) }
|
||||
})
|
||||
})
|
||||
|
||||
const stickerDecorOk = reactive({})
|
||||
watch(
|
||||
() => [...newStickerDecorItems.value, ...revivedStickerDecorItems.value],
|
||||
(arr) => {
|
||||
for (const k of Object.keys(stickerDecorOk)) delete stickerDecorOk[k]
|
||||
if (!Array.isArray(arr)) return
|
||||
for (const x of arr) {
|
||||
const key = String(x?.id || '').trim()
|
||||
if (key) stickerDecorOk[key] = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const avatarOk = reactive({ topStickerOwner: true })
|
||||
watch(heroStickerOwnerAvatarUrl, () => { avatarOk.topStickerOwner = true })
|
||||
|
||||
const avatarFallback = (name) => {
|
||||
const s = String(name || '').trim()
|
||||
return s ? s[0] : '?'
|
||||
}
|
||||
|
||||
const narrativeSegments = computed(() => {
|
||||
const out = []
|
||||
|
||||
if (sentStickerCount.value > 0) {
|
||||
appendParsedText(
|
||||
out,
|
||||
`这一年,你用 ${formatInt(sentStickerCount.value)} 张表情包把聊天变得更有温度;在 ${formatInt(
|
||||
stickerActiveDays.value
|
||||
)} 个活跃日里,日均 ${stickerPerActiveDayText.value} 张。`
|
||||
)
|
||||
} else {
|
||||
appendParsedText(out, '这一年你几乎没发过表情包。')
|
||||
}
|
||||
|
||||
if (peakHour.value !== null && peakWeekdayName.value) {
|
||||
appendParsedText(out, `你最活跃的时刻是 ${peakWeekdayName.value} ${peakHour.value}:00。`)
|
||||
}
|
||||
|
||||
let hasTail = false
|
||||
if (heroSticker.value) {
|
||||
appendParsedText(out, '年度 C 位表情是 ')
|
||||
if (heroStickerUrl.value) {
|
||||
out.push({ type: 'sticker', src: heroStickerUrl.value, alt: '年度 C 位表情', sizeEm: 1.22 })
|
||||
} else {
|
||||
appendParsedText(out, '(图片缺失)')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(heroSticker.value?.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
}
|
||||
|
||||
const topWechat = topWechatEmojis.value[0]
|
||||
const topText = topTextEmojis.value[0]
|
||||
if (topWechat) {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}你最常用的小黄脸是 `)
|
||||
if (topWechat.assetPath) {
|
||||
out.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(topWechat.assetPath),
|
||||
alt: topWechat.key || 'emoji',
|
||||
sizeEm: 1.16
|
||||
})
|
||||
} else {
|
||||
appendParsedText(out, topWechat.key || '')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(topWechat.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
} else if (topText) {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}在文字聊天里,你最常打的小黄脸是 `)
|
||||
if (topText.assetPath) {
|
||||
out.push({
|
||||
type: 'emoji',
|
||||
src: resolveEmojiAsset(topText.assetPath),
|
||||
alt: topText.key || 'emoji',
|
||||
sizeEm: 1.16
|
||||
})
|
||||
} else {
|
||||
appendParsedText(out, topText.key || '')
|
||||
}
|
||||
appendParsedText(out, `(${formatInt(Number(topText.count || 0))} 次)`)
|
||||
hasTail = true
|
||||
} else {
|
||||
appendParsedText(out, `${hasTail ? ',' : ''}今年没有命中可识别的小黄脸`)
|
||||
hasTail = true
|
||||
}
|
||||
|
||||
const topUnicode = topUnicodeEmojis.value[0]
|
||||
if (topUnicode) {
|
||||
appendParsedText(
|
||||
out,
|
||||
`${hasTail ? ',' : ''}普通 Emoji 最常用 ${topUnicode.emoji}(${formatInt(Number(topUnicode.count || 0))} 次)。`
|
||||
)
|
||||
} else if (hasTail) {
|
||||
appendParsedText(out, '。')
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const cardRoot = ref(null)
|
||||
const isCardVisible = ref(false)
|
||||
const cursorTrails = ref([])
|
||||
const cursorFxEnabled = ref(true)
|
||||
const allWechatEmojiAssets = computed(() => {
|
||||
const values = Object.values(WechatEmojiTable || {})
|
||||
const uniq = new Set()
|
||||
for (const x of values) {
|
||||
const p = resolveEmojiAsset(String(x || '').trim())
|
||||
if (p) uniq.add(p)
|
||||
}
|
||||
return Array.from(uniq)
|
||||
})
|
||||
|
||||
let trailSeq = 0
|
||||
let lastSpawnAt = 0
|
||||
let lastSpawnX = -9999
|
||||
let lastSpawnY = -9999
|
||||
const TRAIL_LIFETIME_MS = 780
|
||||
const MIN_SPAWN_INTERVAL_MS = 28
|
||||
const MIN_SPAWN_DISTANCE = 10
|
||||
const MAX_TRAIL_COUNT = 32
|
||||
|
||||
const checkCardVisible = () => {
|
||||
if (!process.client) return false
|
||||
const rect = cardRoot.value?.getBoundingClientRect?.()
|
||||
if (!rect) return false
|
||||
const vh = window.innerHeight || 0
|
||||
const vw = window.innerWidth || 0
|
||||
return rect.bottom > vh * 0.12 && rect.top < vh * 0.88 && rect.right > 0 && rect.left < vw
|
||||
}
|
||||
|
||||
const spawnCursorTrail = (x, y) => {
|
||||
const pool = allWechatEmojiAssets.value
|
||||
if (!pool.length) return
|
||||
const src = pool[Math.floor(Math.random() * pool.length)]
|
||||
const item = {
|
||||
id: ++trailSeq,
|
||||
x,
|
||||
y,
|
||||
src,
|
||||
size: 18 + Math.floor(Math.random() * 8),
|
||||
driftX: Math.round((Math.random() - 0.5) * 30),
|
||||
driftY: 20 + Math.floor(Math.random() * 20)
|
||||
}
|
||||
cursorTrails.value = [...cursorTrails.value.slice(-MAX_TRAIL_COUNT), item]
|
||||
setTimeout(() => {
|
||||
cursorTrails.value = cursorTrails.value.filter((t) => t.id !== item.id)
|
||||
}, TRAIL_LIFETIME_MS)
|
||||
}
|
||||
|
||||
const onWindowPointerMove = (e) => {
|
||||
if (!process.client || !cursorFxEnabled.value) return
|
||||
if (e?.pointerType === 'touch') return
|
||||
const visible = checkCardVisible()
|
||||
isCardVisible.value = visible
|
||||
if (!visible) return
|
||||
|
||||
const x = Number(e.clientX)
|
||||
const y = Number(e.clientY)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||
|
||||
const now = performance.now()
|
||||
const dx = x - lastSpawnX
|
||||
const dy = y - lastSpawnY
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if ((now - lastSpawnAt) < MIN_SPAWN_INTERVAL_MS && dist < MIN_SPAWN_DISTANCE) return
|
||||
|
||||
lastSpawnAt = now
|
||||
lastSpawnX = x
|
||||
lastSpawnY = y
|
||||
spawnCursorTrail(x, y)
|
||||
}
|
||||
|
||||
const onWindowPointerLeave = () => {
|
||||
lastSpawnX = -9999
|
||||
lastSpawnY = -9999
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
cursorFxEnabled.value = !mq.matches
|
||||
} catch {
|
||||
cursorFxEnabled.value = true
|
||||
}
|
||||
isCardVisible.value = checkCardVisible()
|
||||
window.addEventListener('pointermove', onWindowPointerMove, { passive: true })
|
||||
window.addEventListener('pointerleave', onWindowPointerLeave, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (process.client) {
|
||||
window.removeEventListener('pointermove', onWindowPointerMove)
|
||||
window.removeEventListener('pointerleave', onWindowPointerLeave)
|
||||
}
|
||||
cursorTrails.value = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-inline-emoji {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-cursor-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.emoji-cursor-item {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.94;
|
||||
animation: emoji-cursor-float 780ms ease-out forwards;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
@keyframes emoji-cursor-float {
|
||||
0% {
|
||||
opacity: 0.94;
|
||||
transform: translate(-50%, -50%) scale(0.88);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(-50% + var(--drift-x)), calc(-50% - var(--drift-y))) scale(1.16);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="vb-stack relative select-none" :style="stackStyle">
|
||||
<div
|
||||
v-for="(card, index) in cards"
|
||||
:key="String(card.id)"
|
||||
class="vb-stack-card absolute top-1/2 left-1/2 overflow-hidden rounded-2xl bg-white/80 shadow-sm"
|
||||
:style="{
|
||||
width: `${dims.width}px`,
|
||||
height: `${dims.height}px`,
|
||||
touchAction: index === cards.length - 1 ? 'none' : 'auto',
|
||||
pointerEvents: index === cards.length - 1 ? 'auto' : 'none'
|
||||
}"
|
||||
:ref="(el) => onCardRef(card.id, el)"
|
||||
@pointerdown="(e) => onPointerDown(e, card.id, index)"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
>
|
||||
<img :src="card.img" class="w-full h-full object-cover" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
type StackCard = {
|
||||
id: string | number
|
||||
img: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
randomRotation?: boolean
|
||||
sensitivity?: number
|
||||
sendToBackOnClick?: boolean
|
||||
cardDimensions?: { width?: number; height?: number }
|
||||
cardsData?: StackCard[]
|
||||
}>(),
|
||||
{
|
||||
randomRotation: false,
|
||||
sensitivity: 180,
|
||||
sendToBackOnClick: true,
|
||||
cardDimensions: () => ({ width: 200, height: 200 }),
|
||||
cardsData: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const dims = computed(() => {
|
||||
const w = Number(props.cardDimensions?.width)
|
||||
const h = Number(props.cardDimensions?.height)
|
||||
return {
|
||||
width: Number.isFinite(w) && w > 0 ? Math.floor(w) : 200,
|
||||
height: Number.isFinite(h) && h > 0 ? Math.floor(h) : 200
|
||||
}
|
||||
})
|
||||
|
||||
const stackStyle = computed(() => ({
|
||||
width: `${dims.value.width}px`,
|
||||
height: `${dims.value.height}px`
|
||||
}))
|
||||
|
||||
const cards = ref<StackCard[]>([])
|
||||
const elMap = new Map<string, HTMLDivElement>()
|
||||
const rotationMap = ref(new Map<string, number>())
|
||||
const mounted = ref(false)
|
||||
|
||||
const ROT_RANGE_DEG = 7
|
||||
const STACK_OFFSET_X = 2
|
||||
const STACK_OFFSET_Y = 4
|
||||
const STACK_SCALE_STEP = 0.03
|
||||
const MAX_TILT_DEG = 22
|
||||
|
||||
function normalizeCards(data: unknown): StackCard[] {
|
||||
const raw = Array.isArray(data) ? data : []
|
||||
const out: StackCard[] = []
|
||||
for (const item of raw) {
|
||||
const id = (item as any)?.id
|
||||
const img = String((item as any)?.img || '').trim()
|
||||
if (id === null || id === undefined) continue
|
||||
if (!img) continue
|
||||
out.push({ id, img })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureRotations(list: StackCard[]) {
|
||||
const next = new Map(rotationMap.value)
|
||||
if (!props.randomRotation) {
|
||||
for (const c of list) next.set(String(c.id), 0)
|
||||
rotationMap.value = next
|
||||
return
|
||||
}
|
||||
for (const c of list) {
|
||||
const key = String(c.id)
|
||||
if (next.has(key)) continue
|
||||
next.set(key, Math.round((Math.random() * 2 - 1) * ROT_RANGE_DEG))
|
||||
}
|
||||
rotationMap.value = next
|
||||
}
|
||||
|
||||
function onCardRef(id: StackCard['id'], el: Element | null) {
|
||||
const key = String(id)
|
||||
if (el && el instanceof HTMLDivElement) elMap.set(key, el)
|
||||
else elMap.delete(key)
|
||||
}
|
||||
|
||||
function applyLayout(animate: boolean) {
|
||||
if (!mounted.value) return
|
||||
const total = cards.value.length
|
||||
if (total === 0) return
|
||||
|
||||
cards.value.forEach((card, idx) => {
|
||||
const key = String(card.id)
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
const orderFromTop = total - 1 - idx
|
||||
const x = orderFromTop * STACK_OFFSET_X
|
||||
const y = orderFromTop * STACK_OFFSET_Y
|
||||
const scale = Math.max(0.88, 1 - orderFromTop * STACK_SCALE_STEP)
|
||||
const rotation = rotationMap.value.get(key) ?? 0
|
||||
|
||||
const tweenVars: gsap.TweenVars = {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
scale,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
zIndex: idx + 1,
|
||||
transformOrigin: 'center center',
|
||||
ease: 'power3.out',
|
||||
duration: animate ? 0.32 : 0
|
||||
}
|
||||
|
||||
gsap.killTweensOf(el)
|
||||
if (animate) gsap.to(el, tweenVars)
|
||||
else gsap.set(el, tweenVars)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.cardsData,
|
||||
(val) => {
|
||||
const nextCards = normalizeCards(val)
|
||||
cards.value = nextCards
|
||||
ensureRotations(nextCards)
|
||||
nextTick(() => applyLayout(false))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.randomRotation, props.cardDimensions?.width, props.cardDimensions?.height] as const,
|
||||
() => {
|
||||
ensureRotations(cards.value)
|
||||
nextTick(() => applyLayout(false))
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
applyLayout(false)
|
||||
})
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
||||
|
||||
let activePointerId: number | null = null
|
||||
let activeCardId: string | null = null
|
||||
let startClientX = 0
|
||||
let startClientY = 0
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startRotationZ = 0
|
||||
let startScale = 1
|
||||
let lastDx = 0
|
||||
let lastDy = 0
|
||||
|
||||
function sendToBack(id: string) {
|
||||
if (cards.value.length < 2) return
|
||||
const list = cards.value.slice()
|
||||
const idx = list.findIndex((c) => String(c.id) === id)
|
||||
if (idx < 0) return
|
||||
const [card] = list.splice(idx, 1)
|
||||
if (!card) return
|
||||
cards.value = [card, ...list]
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent, id: StackCard['id'], index: number) {
|
||||
if (activePointerId !== null) return
|
||||
if (index !== cards.value.length - 1) return
|
||||
|
||||
const key = String(id)
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
activePointerId = e.pointerId
|
||||
activeCardId = key
|
||||
startClientX = e.clientX
|
||||
startClientY = e.clientY
|
||||
startX = Number(gsap.getProperty(el, 'x')) || 0
|
||||
startY = Number(gsap.getProperty(el, 'y')) || 0
|
||||
startRotationZ = Number(gsap.getProperty(el, 'rotation')) || 0
|
||||
startScale = Number(gsap.getProperty(el, 'scale')) || 1
|
||||
lastDx = 0
|
||||
lastDy = 0
|
||||
|
||||
try {
|
||||
el.setPointerCapture(e.pointerId)
|
||||
} catch {}
|
||||
|
||||
gsap.killTweensOf(el)
|
||||
gsap.set(el, { zIndex: 999 })
|
||||
gsap.to(el, { scale: startScale * 1.03, duration: 0.12, ease: 'power2.out' })
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
const key = activeCardId
|
||||
if (!key) return
|
||||
const el = elMap.get(key)
|
||||
if (!el) return
|
||||
|
||||
const dx = e.clientX - startClientX
|
||||
const dy = e.clientY - startClientY
|
||||
lastDx = dx
|
||||
lastDy = dy
|
||||
|
||||
const w = Math.max(1, dims.value.width)
|
||||
const h = Math.max(1, dims.value.height)
|
||||
const nx = dx / (w * 0.55)
|
||||
const ny = dy / (h * 0.55)
|
||||
const tiltY = clamp(Math.tanh(nx) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||
const tiltX = clamp(-Math.tanh(ny) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||
|
||||
gsap.set(el, {
|
||||
x: startX + dx,
|
||||
y: startY + dy,
|
||||
rotation: startRotationZ + dx / 18,
|
||||
rotationX: tiltX,
|
||||
rotationY: tiltY
|
||||
})
|
||||
}
|
||||
|
||||
function finishPointer(id: number, shouldSendBack: boolean) {
|
||||
const key = activeCardId
|
||||
activePointerId = null
|
||||
activeCardId = null
|
||||
|
||||
const el = key ? elMap.get(key) : null
|
||||
if (el) {
|
||||
try {
|
||||
el.releasePointerCapture(id)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (shouldSendBack && key) sendToBack(key)
|
||||
applyLayout(true)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
const dist = Math.hypot(lastDx, lastDy)
|
||||
const clickLike = dist < 6
|
||||
const shouldSendBack = dist > Number(props.sensitivity || 0) || (props.sendToBackOnClick && clickLike)
|
||||
finishPointer(e.pointerId, shouldSendBack)
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||
finishPointer(e.pointerId, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vb-stack {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.vb-stack-card {
|
||||
will-change: transform;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.vb-stack-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
@@ -329,6 +329,10 @@ const PREVIEW_BY_KIND = {
|
||||
'chat/reply_speed': {
|
||||
summary: '回复速度',
|
||||
question: '谁是你愿意秒回的那个人?'
|
||||
},
|
||||
'emoji/annual_universe': {
|
||||
summary: '梗图年鉴',
|
||||
question: '你这一年最常丢出的表情包是哪张?'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@ export const useApi = () => {
|
||||
media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'],
|
||||
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
|
||||
allow_process_key_extract: !!data.allow_process_key_extract,
|
||||
download_remote_media: !!data.download_remote_media,
|
||||
privacy_mode: !!data.privacy_mode,
|
||||
file_name: data.file_name || null
|
||||
}
|
||||
|
||||
@@ -236,7 +236,13 @@
|
||||
<template v-else>
|
||||
<div v-for="contact in filteredContacts" :key="contact.id"
|
||||
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'"
|
||||
:class="contact.isTop
|
||||
? (selectedContact?.id === contact.id
|
||||
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
|
||||
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
|
||||
: (selectedContact?.id === contact.id
|
||||
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
|
||||
: 'hover:bg-[#eaeaea]')"
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
@@ -501,6 +507,7 @@
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:isSent="message.isSent"
|
||||
:variant="message.linkCardVariant || 'default'"
|
||||
/>
|
||||
<div v-else-if="message.renderType === 'file'"
|
||||
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
|
||||
@@ -651,25 +658,55 @@
|
||||
class="hidden"
|
||||
></audio>
|
||||
</div>
|
||||
<div v-else class="line-clamp-2">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
{{ message.quoteContent }}
|
||||
</span>
|
||||
<div v-else class="min-w-0 flex items-start">
|
||||
<template v-if="isQuotedLink(message)">
|
||||
<div class="line-clamp-2 min-w-0 flex-1">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="getQuotedLinkText(message)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
🔗 {{ getQuotedLinkText(message) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="line-clamp-2 min-w-0 flex-1">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
{{ message.quoteContent }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
|
||||
class="ml-2 my-2 flex-shrink-0 w-[45px] h-[45px] rounded overflow-hidden cursor-pointer"
|
||||
v-if="isQuotedLink(message) && message.quoteThumbUrl && !message._quoteThumbError"
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteThumbUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onQuoteThumbError(message)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isQuotedLink(message) && isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteImageUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="w-full h-full object-contain"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onQuoteImageError(message)"
|
||||
@@ -685,7 +722,7 @@
|
||||
@click.stop="openChatHistoryModal(message)"
|
||||
>
|
||||
<div class="wechat-chat-history-body">
|
||||
<div class="wechat-chat-history-title">{{ message.title || '聊天记录' }}</div>
|
||||
<div class="wechat-chat-history-title">{{ message.title || '合并消息' }}</div>
|
||||
<div class="wechat-chat-history-preview" v-if="getChatHistoryPreviewLines(message).length">
|
||||
<div
|
||||
v-for="(line, idx) in getChatHistoryPreviewLines(message)"
|
||||
@@ -697,14 +734,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-chat-history-bottom">
|
||||
<span>聊天记录</span>
|
||||
<span>合并消息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'transfer'"
|
||||
class="wechat-transfer-card msg-radius"
|
||||
:class="[{ 'wechat-transfer-received': message.transferReceived, 'wechat-transfer-returned': isTransferReturned(message) }, message.isSent ? 'wechat-transfer-sent-side' : 'wechat-transfer-received-side']">
|
||||
:class="[{ 'wechat-transfer-received': message.transferReceived, 'wechat-transfer-returned': isTransferReturned(message), 'wechat-transfer-overdue': isTransferOverdue(message) }, message.isSent ? 'wechat-transfer-sent-side' : 'wechat-transfer-received-side']">
|
||||
<div class="wechat-transfer-content">
|
||||
<img src="/assets/images/wechat/wechat-returned.png" v-if="isTransferReturned(message)" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/overdue.png" v-else-if="isTransferOverdue(message)" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon2.png" v-else-if="message.transferReceived" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon1.png" v-else class="wechat-transfer-icon" alt="">
|
||||
<div class="wechat-transfer-info">
|
||||
@@ -1196,7 +1234,7 @@
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-4 py-3 bg-neutral-100 border-b border-gray-200 flex items-center justify-between">
|
||||
<div class="text-sm text-[#161616] truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
|
||||
<div class="text-sm text-[#161616] truncate">{{ chatHistoryModalTitle || '合并消息' }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5"
|
||||
@@ -1458,6 +1496,10 @@
|
||||
<input type="radio" value="txt" v-model="exportFormat" class="hidden" />
|
||||
<span>TXT</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="html" v-model="exportFormat" class="hidden" />
|
||||
<span>HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1479,6 +1521,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportFormat === 'html'" class="mt-3">
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">HTML 选项</div>
|
||||
<div class="p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<label class="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" v-model="exportDownloadRemoteMedia" :disabled="privacyMode" />
|
||||
<span>允许联网下载链接/引用缩略图(提高离线完整性)</span>
|
||||
</label>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
仅 HTML 生效;会在导出时尝试下载远程缩略图并写入 ZIP(已做安全限制)。隐私模式下自动忽略。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportScope === 'selected'" class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
@@ -1973,6 +2028,7 @@ const messageTypeFilterOptions = [
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'chatHistory', label: '合并消息' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'file', label: '文件' },
|
||||
@@ -2451,13 +2507,15 @@ const exportError = ref('')
|
||||
|
||||
// current: 当前会话(映射为 selected + 单个 username)
|
||||
const exportScope = ref('current') // current | selected | all | groups | singles
|
||||
const exportFormat = ref('json') // json | txt
|
||||
const exportFormat = ref('json') // json | txt | html
|
||||
const exportDownloadRemoteMedia = ref(true)
|
||||
const exportMessageTypeOptions = [
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'chatHistory', label: '合并消息' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'file', label: '文件' },
|
||||
@@ -3026,6 +3084,15 @@ const startChatExport = async () => {
|
||||
|
||||
const selectedTypeSet = new Set(messageTypes.map((t) => String(t || '').trim()))
|
||||
const mediaKindSet = new Set()
|
||||
if (selectedTypeSet.has('chatHistory')) {
|
||||
// 合并消息内部可能包含任意媒体类型;即使只勾选了 chatHistory,也需要打包媒体才可离线查看。
|
||||
mediaKindSet.add('image')
|
||||
mediaKindSet.add('emoji')
|
||||
mediaKindSet.add('video')
|
||||
mediaKindSet.add('video_thumb')
|
||||
mediaKindSet.add('voice')
|
||||
mediaKindSet.add('file')
|
||||
}
|
||||
if (selectedTypeSet.has('image')) mediaKindSet.add('image')
|
||||
if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
|
||||
if (selectedTypeSet.has('video')) {
|
||||
@@ -3054,6 +3121,7 @@ const startChatExport = async () => {
|
||||
message_types: messageTypes,
|
||||
include_media: includeMedia,
|
||||
media_kinds: mediaKinds,
|
||||
download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value,
|
||||
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
|
||||
privacy_mode: !!privacyMode.value,
|
||||
file_name: exportFileName.value || null
|
||||
@@ -3226,12 +3294,31 @@ const isQuotedImage = (message) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const isQuotedLink = (message) => {
|
||||
const t = String(message?.quoteType || '').trim()
|
||||
if (t === '49') return true
|
||||
return /^\[链接\]\s*/.test(String(message?.quoteContent || '').trim())
|
||||
}
|
||||
|
||||
const getQuotedLinkText = (message) => {
|
||||
const raw = String(message?.quoteContent || '').trim()
|
||||
if (!raw) return ''
|
||||
const stripped = raw.replace(/^\[链接\]\s*/u, '').trim()
|
||||
return stripped || raw
|
||||
}
|
||||
|
||||
const onQuoteImageError = (message) => {
|
||||
try {
|
||||
if (message) message._quoteImageError = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onQuoteThumbError = (message) => {
|
||||
try {
|
||||
if (message) message._quoteThumbError = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const playQuoteVoice = (message) => {
|
||||
playVoice({ id: getQuoteVoiceId(message) })
|
||||
}
|
||||
@@ -3961,6 +4048,16 @@ const isTransferReturned = (message) => {
|
||||
return text.includes('退回') || text.includes('退还')
|
||||
}
|
||||
|
||||
const isTransferOverdue = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (paySubType === '10') return true
|
||||
const s = String(message?.transferStatus || '').trim()
|
||||
const c = String(message?.content || '').trim()
|
||||
const text = `${s} ${c}`.trim()
|
||||
if (!text) return false
|
||||
return text.includes('过期')
|
||||
}
|
||||
|
||||
const getTransferTitle = (message) => {
|
||||
const paySubType = String(message.paySubType || '').trim()
|
||||
// paysubtype 含义:
|
||||
@@ -3969,7 +4066,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 +4233,7 @@ const loadSessionsForSelectedAccount = async () => {
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
isTop: !!s.isTop,
|
||||
username: s.username
|
||||
}))
|
||||
|
||||
@@ -4209,6 +4307,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
isTop: !!s.isTop,
|
||||
username: s.username
|
||||
}))
|
||||
|
||||
@@ -4401,6 +4500,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 +4555,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,
|
||||
@@ -4876,7 +4993,7 @@ const openChatHistoryQuote = (rec) => {
|
||||
|
||||
const openChatHistoryModal = (message) => {
|
||||
if (!process.client) return
|
||||
chatHistoryModalTitle.value = String(message?.title || '聊天记录')
|
||||
chatHistoryModalTitle.value = String(message?.title || '合并消息')
|
||||
|
||||
const recordItem = String(message?.recordItem || '').trim()
|
||||
const parsed = parseChatHistoryRecord(recordItem)
|
||||
@@ -5331,7 +5448,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 +5474,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 +6107,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,
|
||||
@@ -5964,6 +6141,24 @@ const LinkCard = defineComponent({
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 过期的转账样式 */
|
||||
.wechat-transfer-overdue {
|
||||
background: #E9CFB3;
|
||||
}
|
||||
|
||||
.wechat-transfer-overdue::after {
|
||||
background: #E9CFB3;
|
||||
}
|
||||
|
||||
.wechat-transfer-overdue .wechat-transfer-amount,
|
||||
.wechat-transfer-overdue .wechat-transfer-status {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wechat-transfer-overdue .wechat-transfer-bottom span {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 红包消息样式 - 微信风格 */
|
||||
.wechat-redpacket-card {
|
||||
width: 210px;
|
||||
@@ -6258,6 +6453,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);
|
||||
|
||||
@@ -609,10 +609,17 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
|
||||
if (h) parts.set('height', h)
|
||||
if (/^\d+$/.test(ts)) parts.set('total_size', ts)
|
||||
parts.set('idx', String(Number(sizeIdx) || 0))
|
||||
const pid = String(post?.id || '').trim()
|
||||
if (pid) parts.set('post_id', pid)
|
||||
|
||||
const mid = String(m?.id || '').trim()
|
||||
if (mid) parts.set('media_id', mid)
|
||||
|
||||
const mtype = String(m?.type || '').trim()
|
||||
if (mtype) parts.set('media_type', mtype)
|
||||
|
||||
if (pick) parts.set('pick', pick)
|
||||
if (!pick && snsAvoidOtherPicked.value) {
|
||||
const pid = String(post?.id || '').trim()
|
||||
if (pid) parts.set('post_id', pid)
|
||||
parts.set('avoid_picked', '1')
|
||||
parts.set('pv', String(snsMediaOverrideRev.value || '0'))
|
||||
}
|
||||
|
||||
@@ -163,6 +163,12 @@
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card04EmojiUniverse
|
||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<WrappedCardShell
|
||||
v-else
|
||||
:card-id="Number(c?.id || (idx + 1))"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -618,6 +618,73 @@ def _normalize_xml_url(url: str) -> 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 _is_mp_weixin_feed_article_url(url: str) -> bool:
|
||||
"""Detect WeChat's PC feed/recommendation mp.weixin.qq.com share URLs.
|
||||
|
||||
These links often carry an `exptype` like:
|
||||
masonry_feed_brief_content_elite_for_pcfeeds_u2i
|
||||
|
||||
WeChat desktop tends to render them in a cover-card style (image + bottom title),
|
||||
so we use this as a hint to choose the 'cover' linkStyle.
|
||||
"""
|
||||
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = urlparse(u)
|
||||
q = parse_qs(parsed.query or "")
|
||||
for v in (q.get("exptype") or []):
|
||||
if "masonry_feed" in str(v or "").lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "exptype=masonry_feed" in u.lower()
|
||||
|
||||
|
||||
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))
|
||||
|
||||
# 公众号文章中「封面图 + 底栏标题」卡片特征:摘要以 #话题# 风格为主。
|
||||
cover_like = bool(
|
||||
is_official_article
|
||||
and (
|
||||
d.startswith("#")
|
||||
or hashtag_count >= 2
|
||||
or _is_mp_weixin_feed_article_url(url)
|
||||
)
|
||||
)
|
||||
link_style = "cover" if cover_like 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 +756,65 @@ def _extract_refermsg_block(xml_text: str) -> str:
|
||||
return (m.group(1) or "").strip() if m else ""
|
||||
|
||||
|
||||
def _extract_refermsg_content(refer_block: str) -> str:
|
||||
if not refer_block:
|
||||
return ""
|
||||
|
||||
cdata_match = re.search(
|
||||
r"<content\b[^>]*>\s*<!\[CDATA\[(.*?)\]\]>\s*</content>",
|
||||
refer_block,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if cdata_match:
|
||||
return str(cdata_match.group(1) or "").strip()
|
||||
|
||||
return _extract_xml_tag_text(refer_block, "content")
|
||||
|
||||
|
||||
def _summarize_nested_quote_content(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
lower = candidate.lower()
|
||||
if "<msg" not in lower and "<appmsg" not in lower:
|
||||
return candidate
|
||||
|
||||
for tag in ("title", "des"):
|
||||
value = _extract_xml_tag_text(candidate, tag)
|
||||
if value:
|
||||
return value
|
||||
|
||||
content_value = _extract_xml_tag_text(candidate, "content")
|
||||
if content_value and (not str(content_value).lstrip().startswith("<")):
|
||||
return content_value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_nested_quote_thumb_url(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
probes = [candidate]
|
||||
|
||||
if candidate.startswith("wxid_"):
|
||||
colon = candidate.find(":")
|
||||
if 0 < colon <= 64:
|
||||
rest = candidate[colon + 1 :].strip()
|
||||
if rest:
|
||||
probes.append(rest)
|
||||
|
||||
for probe in probes:
|
||||
for key in ("thumburl", "cdnthumburl", "cdnthumurl", "coverurl", "cover"):
|
||||
value = _normalize_xml_url(_extract_xml_tag_or_attr(probe, key))
|
||||
if value:
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _infer_transfer_status_text(
|
||||
is_sent: bool,
|
||||
paysubtype: str,
|
||||
@@ -702,7 +828,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 +844,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 +896,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str:
|
||||
if not xml_text:
|
||||
return ""
|
||||
|
||||
v = _extract_xml_tag_text(xml_text, "fromusername")
|
||||
probe_text = xml_text
|
||||
try:
|
||||
# Avoid picking nested quoted-message sender from <refermsg>.
|
||||
probe_text = re.sub(
|
||||
r"(<refermsg[^>]*>.*?</refermsg>)",
|
||||
"",
|
||||
xml_text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
except Exception:
|
||||
probe_text = xml_text
|
||||
|
||||
v = _extract_xml_tag_text(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
v = _extract_xml_attr(xml_text, "fromusername")
|
||||
v = _extract_xml_attr(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
@@ -844,8 +982,18 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"recordItem": record_item or "",
|
||||
}
|
||||
|
||||
if app_type in (5, 68) and url:
|
||||
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
|
||||
if app_type in (4, 5, 68) and url:
|
||||
# Many appmsg link cards (notably Bilibili shares with <type>4</type>) include a <patMsg> metadata block.
|
||||
# DO NOT treat "<patmsg" presence as a pat message: it would misclassify normal link cards as "[拍一拍]".
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_text(text, "thumburl") or _extract_xml_tag_text(text, "cdnthumburl")
|
||||
)
|
||||
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 +1002,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 +1057,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 +1074,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 +1095,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,11 +1126,15 @@ 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,
|
||||
}
|
||||
|
||||
if app_type == 62 or "<patmsg" in lower or 'type="patmsg"' in lower or "type='patmsg'" in lower:
|
||||
# Some versions may mark pat messages via sysmsg/appmsg tag attribute: <sysmsg type="patmsg">...</sysmsg>.
|
||||
# Be strict here: lots of non-pat appmsg payloads still carry a nested <patMsg>...</patMsg> metadata block.
|
||||
patmsg_attr = bool(re.search(r"<(sysmsg|appmsg)\b[^>]*\btype=['\"]patmsg['\"]", lower))
|
||||
if app_type == 62 or patmsg_attr:
|
||||
return {"renderType": "system", "content": "[拍一拍]"}
|
||||
|
||||
if app_type == 2000 or (
|
||||
@@ -1818,10 +1994,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 +2014,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 +2033,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 +2060,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 +2110,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 +2152,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,
|
||||
|
||||
@@ -14,8 +14,10 @@ import subprocess
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
import logging
|
||||
import asyncio
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
@@ -203,7 +205,56 @@ def get_db_key_workflow():
|
||||
|
||||
# 远程 API 配置
|
||||
REMOTE_URL = "https://view.free.c3o.re/dashboard"
|
||||
NEXT_ACTION_ID = "7c8f99280c70626ccf5960cc4a68f368197e15f8e9"
|
||||
BASE_URL = "https://view.free.c3o.re" # 用于拼接js
|
||||
|
||||
# NEXT_ACTION_ID = "7c8f99280c70626ccf5960cc4a68f368197e15f8e9" # 不可以硬编码
|
||||
|
||||
|
||||
async def fetch_js_and_scan(client: httpx.AsyncClient, js_path: str) -> Optional[str]:
|
||||
"""
|
||||
异步下载单个 JS 文件并匹配 Action ID
|
||||
"""
|
||||
full_url = f"{BASE_URL}{js_path}" if js_path.startswith("/") else js_path
|
||||
try:
|
||||
response = await client.get(full_url)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
content = response.text
|
||||
|
||||
action_id_pattern = re.compile(r'createServerReference.*?["\']([a-f0-9]{42})["\'].*?["\']getUserConfigFromBytes["\']')
|
||||
|
||||
match = action_id_pattern.search(content)
|
||||
if match:
|
||||
found_id = match.group(1)
|
||||
return found_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching {js_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_next_action_id_async() -> str:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
|
||||
resp = await client.get(REMOTE_URL)
|
||||
html = resp.text
|
||||
|
||||
js_file_pattern = re.compile(r'src="(/_next/static/chunks/[^"]+\.js)"')
|
||||
js_files = set(js_file_pattern.findall(html))
|
||||
|
||||
if not js_files:
|
||||
raise Exception("未找到任何 Next.js chunk 文件,可能页面结构已变动。")
|
||||
|
||||
tasks = [fetch_js_and_scan(client, js_path) for js_path in js_files]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
for res in results:
|
||||
if res:
|
||||
return res
|
||||
|
||||
raise Exception("遍历了所有 JS 文件,但未找到匹配的 createServerReference ID。")
|
||||
|
||||
|
||||
def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
@@ -221,41 +272,20 @@ def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
# def get_local_config_sha3_224() -> bytes:
|
||||
# """
|
||||
# 不要在意,抽象的实现 哈哈哈
|
||||
# """
|
||||
# content = json.dumps({
|
||||
# "wxfile_dir": "C:\\Users\\17078\\xwechat_files",
|
||||
# "weixin_id_folder": "wxid_lnyf4hdo9csb12_f1c4",
|
||||
# "cache_dir": "C:\\Users\\17078\\Desktop\\wxDBHook\\test\\wx-dat\\wx-dat\\.cache",
|
||||
# "db_key": "",
|
||||
# "port": 8001
|
||||
# }, indent=4).encode("utf-8")
|
||||
#
|
||||
# # 计算 SHA3-224
|
||||
# digest = hashlib.sha3_224(content).digest()
|
||||
# return digest
|
||||
|
||||
# async def log_request(request):
|
||||
# print(f"--- Request Raw ---")
|
||||
# print(f"{request.method} {request.url} {request.extensions.get('http_version', b'HTTP/1.1').decode()}")
|
||||
# for name, value in request.headers.items():
|
||||
# print(f"{name}: {value}")
|
||||
#
|
||||
# print()
|
||||
#
|
||||
# body = request.read()
|
||||
# if body:
|
||||
# print(body.decode(errors='replace'))
|
||||
# print(f"-------------------\n")
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
logger.info("尝试获取next_action_id")
|
||||
try:
|
||||
next_action_id = await _get_next_action_id_async()
|
||||
logger.info(f"获取next_action_id成功: {next_action_id}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"获取next_action_id失败:{e}")
|
||||
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取密钥...")
|
||||
|
||||
try:
|
||||
@@ -274,7 +304,7 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
|
||||
headers = {
|
||||
"Accept": "text/x-component",
|
||||
"Next-Action": NEXT_ACTION_ID,
|
||||
"Next-Action": next_action_id,
|
||||
"Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
|
||||
"Origin": "https://view.free.c3o.re",
|
||||
"Referer": "https://view.free.c3o.re/dashboard",
|
||||
|
||||
@@ -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,197 @@ 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 的那条校正为“已收款”
|
||||
|
||||
def _is_transfer_expired_system_message(text: Any) -> bool:
|
||||
content = str(text or "").strip()
|
||||
if not content:
|
||||
return False
|
||||
if "转账" not in content or "过期" not in content:
|
||||
return False
|
||||
if "未接收" in content and ("24小时" in content or "二十四小时" in content):
|
||||
return True
|
||||
return "已过期" in content and ("收款方" in content or "转账" in content)
|
||||
|
||||
def _mark_pending_transfers_expired_by_system_messages() -> set[str]:
|
||||
expired_system_times: list[int] = []
|
||||
pending_candidates: list[tuple[int, int]] = [] # (index, createTime)
|
||||
|
||||
for idx, msg in enumerate(merged):
|
||||
rt = str(msg.get("renderType") or "").strip()
|
||||
if rt == "system":
|
||||
if _is_transfer_expired_system_message(msg.get("content")):
|
||||
try:
|
||||
ts = int(msg.get("createTime") or 0)
|
||||
except Exception:
|
||||
ts = 0
|
||||
if ts > 0:
|
||||
expired_system_times.append(ts)
|
||||
continue
|
||||
|
||||
if rt != "transfer":
|
||||
continue
|
||||
|
||||
pst = str(msg.get("paySubType") or "").strip()
|
||||
if pst not in ("1", "8"):
|
||||
continue
|
||||
|
||||
try:
|
||||
ts = int(msg.get("createTime") or 0)
|
||||
except Exception:
|
||||
ts = 0
|
||||
if ts <= 0:
|
||||
continue
|
||||
|
||||
pending_candidates.append((idx, ts))
|
||||
|
||||
if not expired_system_times or not pending_candidates:
|
||||
return set()
|
||||
|
||||
used_pending_indexes: set[int] = set()
|
||||
expired_transfer_ids: set[str] = set()
|
||||
|
||||
# 过期系统提示通常出现在转账发起约 24 小时后。
|
||||
# 为避免误匹配,要求时间差落在 [22h, 26h] 范围内,并选择最接近 24h 的待收款消息。
|
||||
for sys_ts in sorted(expired_system_times):
|
||||
best_index = -1
|
||||
best_distance = 10**9
|
||||
|
||||
for idx, transfer_ts in pending_candidates:
|
||||
if idx in used_pending_indexes:
|
||||
continue
|
||||
delta = sys_ts - transfer_ts
|
||||
if delta < 0:
|
||||
continue
|
||||
if delta < 22 * 3600 or delta > 26 * 3600:
|
||||
continue
|
||||
|
||||
distance = abs(delta - 24 * 3600)
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best_index = idx
|
||||
|
||||
if best_index < 0:
|
||||
continue
|
||||
|
||||
used_pending_indexes.add(best_index)
|
||||
transfer_msg = merged[best_index]
|
||||
transfer_msg["paySubType"] = "10"
|
||||
transfer_msg["transferStatus"] = "已过期"
|
||||
|
||||
tid = str(transfer_msg.get("transferId") or "").strip()
|
||||
if tid:
|
||||
expired_transfer_ids.add(tid)
|
||||
|
||||
return expired_transfer_ids
|
||||
|
||||
expired_transfer_ids = _mark_pending_transfers_expired_by_system_messages()
|
||||
|
||||
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
|
||||
if tid in expired_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 +2932,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 +3307,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 +3379,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 +3562,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 +3706,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 +3739,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 +3768,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 +3814,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 +3916,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 +3982,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 +4042,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 +4067,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 +4426,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 +4462,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 +4491,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 +4537,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 +4696,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 +4749,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 +4774,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 +4811,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 +4850,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 +4859,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 +4927,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 +5071,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 +6032,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 +6055,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 +6094,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 +6106,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 +6118,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 +6130,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 "
|
||||
|
||||
@@ -12,17 +12,31 @@ from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
ExportFormat = Literal["json", "txt"]
|
||||
ExportFormat = Literal["json", "txt", "html"]
|
||||
ExportScope = Literal["selected", "all", "groups", "singles"]
|
||||
MediaKind = Literal["image", "emoji", "video", "video_thumb", "voice", "file"]
|
||||
MessageType = Literal["text", "image", "emoji", "video", "voice", "file", "link", "transfer", "redPacket", "system", "quote", "voip"]
|
||||
MessageType = Literal[
|
||||
"text",
|
||||
"image",
|
||||
"emoji",
|
||||
"video",
|
||||
"voice",
|
||||
"chatHistory",
|
||||
"file",
|
||||
"link",
|
||||
"transfer",
|
||||
"redPacket",
|
||||
"system",
|
||||
"quote",
|
||||
"voip",
|
||||
]
|
||||
|
||||
|
||||
class ChatExportCreateRequest(BaseModel):
|
||||
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||
scope: ExportScope = Field("selected", description="导出范围:selected=指定会话;all=全部;groups=仅群聊;singles=仅单聊")
|
||||
usernames: list[str] = Field(default_factory=list, description="会话 username 列表(scope=selected 时使用)")
|
||||
format: ExportFormat = Field("json", description="导出格式:json 或 txt(zip 内每个会话一个文件)")
|
||||
format: ExportFormat = Field("json", description="导出格式:json/txt/html(zip 内每个会话一个文件;html 可离线打开 index.html 查看)")
|
||||
start_time: Optional[int] = Field(None, description="起始时间(Unix 秒,含)")
|
||||
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
|
||||
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
|
||||
@@ -41,6 +55,10 @@ class ChatExportCreateRequest(BaseModel):
|
||||
False,
|
||||
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
|
||||
)
|
||||
download_remote_media: bool = Field(
|
||||
False,
|
||||
description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)",
|
||||
)
|
||||
privacy_mode: bool = Field(
|
||||
False,
|
||||
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
|
||||
@@ -64,6 +82,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
|
||||
message_types=req.message_types,
|
||||
output_dir=req.output_dir,
|
||||
allow_process_key_extract=req.allow_process_key_extract,
|
||||
download_remote_media=req.download_remote_media,
|
||||
privacy_mode=req.privacy_mode,
|
||||
file_name=req.file_name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from bisect import bisect_left, bisect_right
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -471,6 +472,21 @@ def _sns_cache_key_from_path(p: Path) -> str:
|
||||
return _normalize_hex32(key)
|
||||
|
||||
|
||||
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
|
||||
"""
|
||||
公式: md5(tid_mediaId_type)
|
||||
Example: 14852422213384352392_14852422213963625090_2 -> 6d479249ca5a090fab5c42c79bc56b89
|
||||
"""
|
||||
if not tid or not media_id:
|
||||
return ""
|
||||
|
||||
raw_key = f"{tid}_{media_id}_{media_type}"
|
||||
|
||||
try:
|
||||
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _resolve_sns_cached_image_path_by_cache_key(
|
||||
*,
|
||||
wxid_dir: Path,
|
||||
@@ -944,19 +960,42 @@ def list_sns_media_candidates(
|
||||
|
||||
@router.get("/api/sns/media", summary="获取朋友圈图片(本地缓存优先)")
|
||||
async def get_sns_media(
|
||||
account: Optional[str] = None,
|
||||
create_time: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
total_size: int = 0,
|
||||
idx: int = 0,
|
||||
avoid_picked: int = 0,
|
||||
post_id: Optional[str] = None,
|
||||
pick: Optional[str] = None,
|
||||
md5: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
create_time: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
total_size: int = 0,
|
||||
idx: int = 0,
|
||||
avoid_picked: int = 0,
|
||||
post_id: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
media_type: int = 2,
|
||||
pick: Optional[str] = None,
|
||||
md5: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
|
||||
if wxid_dir and post_id and media_id:
|
||||
deterministic_key = _generate_sns_cache_key(post_id, media_id, media_type)
|
||||
|
||||
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
|
||||
wxid_dir=wxid_dir,
|
||||
cache_key=deterministic_key,
|
||||
create_time=0
|
||||
)
|
||||
|
||||
if exact_match_path:
|
||||
try:
|
||||
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
|
||||
if payload and str(mtype or "").startswith("image/"):
|
||||
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000" # 确定性缓存可以设置很久
|
||||
resp.headers["X-SNS-Source"] = "deterministic-hash"
|
||||
return resp
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 0) User-picked cache key override (stable across candidate ordering).
|
||||
pick_key = _normalize_hex32(pick)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,15 +16,16 @@ from .cards.card_00_global_overview import build_card_00_global_overview
|
||||
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
||||
from .cards.card_02_message_chars import build_card_02_message_chars
|
||||
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||
# an older partial cache.
|
||||
_IMPLEMENTED_UPTO_ID = 3
|
||||
_IMPLEMENTED_UPTO_ID = 4
|
||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||
_CACHE_VERSION = 9
|
||||
_CACHE_VERSION = 15
|
||||
|
||||
|
||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||
@@ -58,6 +59,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
||||
"category": "B",
|
||||
"kind": "chat/reply_speed",
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "这一年,你的表情包里藏了多少心情?",
|
||||
"scope": "global",
|
||||
"category": "B",
|
||||
"kind": "emoji/annual_universe",
|
||||
},
|
||||
)
|
||||
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
||||
|
||||
@@ -274,7 +282,7 @@ def build_wrapped_annual_response(
|
||||
) -> dict[str, Any]:
|
||||
"""Build annual wrapped response for the given account/year.
|
||||
|
||||
For now we implement cards up to id=3 (plus a meta overview card id=0).
|
||||
For now we implement cards up to id=4 (plus a meta overview card id=0).
|
||||
"""
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
@@ -317,6 +325,8 @@ def build_wrapped_annual_response(
|
||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
||||
# Page 5: reply speed / best chat buddy.
|
||||
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
||||
# Page 6: annual emoji universe / meme almanac.
|
||||
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
||||
|
||||
obj: dict[str, Any] = {
|
||||
"account": account_dir.name,
|
||||
@@ -508,6 +518,8 @@ def build_wrapped_annual_card(
|
||||
card = build_card_02_message_chars(account_dir=account_dir, year=y)
|
||||
elif cid == 3:
|
||||
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||
elif cid == 4:
|
||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||
else:
|
||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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 TestChatAppMessageType4PatMsgRegression(unittest.TestCase):
|
||||
def test_type4_link_with_patmsg_metadata_is_not_misclassified_as_pat(self):
|
||||
raw_text = (
|
||||
"<msg>"
|
||||
'<appmsg appid="wxcb8d4298c6a09bcb" sdkver="0">'
|
||||
"<title>【中配】抽象可能让你的代码变差 - CodeAesthetic</title>"
|
||||
"<des>UP主:黑纹白斑马</des>"
|
||||
"<type>4</type>"
|
||||
"<url>https://b23.tv/au68guF</url>"
|
||||
"<appname>哔哩哔哩</appname>"
|
||||
"<appattach><cdnthumburl>3057020100044b30</cdnthumburl></appattach>"
|
||||
"<patMsg><chatUser /></patMsg>"
|
||||
"</appmsg>"
|
||||
"</msg>"
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("url"), "https://b23.tv/au68guF")
|
||||
self.assertEqual(parsed.get("title"), "【中配】抽象可能让你的代码变差 - CodeAesthetic")
|
||||
self.assertEqual(parsed.get("from"), "哔哩哔哩")
|
||||
self.assertNotEqual(parsed.get("content"), "[拍一拍]")
|
||||
|
||||
def test_type62_is_still_pat(self):
|
||||
raw_text = '<msg><appmsg><title>"A" 拍了拍 "B"</title><type>62</type></appmsg></msg>'
|
||||
parsed = _parse_app_message(raw_text)
|
||||
self.assertEqual(parsed.get("renderType"), "system")
|
||||
self.assertEqual(parsed.get("content"), "[拍一拍]")
|
||||
|
||||
def test_sysmsg_type_patmsg_attr_is_still_pat(self):
|
||||
raw_text = '<sysmsg type="patmsg"><foo>bar</foo></sysmsg>'
|
||||
parsed = _parse_app_message(raw_text)
|
||||
self.assertEqual(parsed.get("renderType"), "system")
|
||||
self.assertEqual(parsed.get("content"), "[拍一拍]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import os
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportChatHistoryModal(unittest.TestCase):
|
||||
_MD5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
record_item = (
|
||||
"<recorditem>"
|
||||
"<datalist>"
|
||||
"<dataitem>"
|
||||
"<datatype>2</datatype>"
|
||||
f"<fullmd5>{self._MD5}</fullmd5>"
|
||||
"</dataitem>"
|
||||
"</datalist>"
|
||||
"</recorditem>"
|
||||
)
|
||||
chat_history_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>19</type>"
|
||||
"<title>聊天记录</title>"
|
||||
"<des>记录预览</des>"
|
||||
f"<recorditem><![CDATA[{record_item}]]></recorditem>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 49, 1, 2, 1735689601, chat_history_xml, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_media_files(self, account_dir: Path) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "aa" / f"{self._MD5}.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
self._seed_media_files(account_dir)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="html",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=True,
|
||||
media_kinds=["image"],
|
||||
message_types=["chatHistory", "image"],
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=False,
|
||||
privacy_mode=False,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def test_chat_history_modal_has_media_index_and_record_item(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
self.assertIn(f"media/images/{self._MD5}.jpg", names)
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn('id="chatHistoryModal"', html_text)
|
||||
self.assertIn('data-wce-chat-history="1"', html_text)
|
||||
self.assertIn('data-record-item-b64="', html_text)
|
||||
self.assertIn('id="wceMediaIndex"', html_text)
|
||||
self.assertIn(self._MD5, html_text)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
@@ -0,0 +1,353 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
_FILE_MD5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
_VOICE_SERVER_ID = 2001
|
||||
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
image_xml = '<msg><img md5="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" cdnthumburl="img_file_id_1" /></msg>'
|
||||
voice_xml = '<msg><voicemsg voicelength="3000" /></msg>'
|
||||
file_md5 = self._FILE_MD5
|
||||
file_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>6</type>"
|
||||
"<title>demo.pdf</title>"
|
||||
"<totallen>2048</totallen>"
|
||||
f"<md5>{file_md5}</md5>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
link_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>5</type>"
|
||||
"<title>示例链接</title>"
|
||||
"<des>这是描述</des>"
|
||||
"<url>https://example.com/</url>"
|
||||
"<thumburl>https://example.com/thumb.jpg</thumburl>"
|
||||
"<sourceusername>gh_test</sourceusername>"
|
||||
"<sourcedisplayname>测试公众号</sourcedisplayname>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
chat_history_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>19</type>"
|
||||
"<title>聊天记录</title>"
|
||||
"<des>记录预览</des>"
|
||||
"<recorditem><desc>张三: hi\n李四: ok</desc></recorditem>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
transfer_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>2000</type>"
|
||||
"<title>微信转账</title>"
|
||||
"<wcpayinfo>"
|
||||
"<pay_memo>转账备注</pay_memo>"
|
||||
"<feedesc>¥1.23</feedesc>"
|
||||
"<paysubtype>3</paysubtype>"
|
||||
"<transferid>transfer_123</transferid>"
|
||||
"</wcpayinfo>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
red_packet_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>2001</type>"
|
||||
"<title>红包</title>"
|
||||
"<wcpayinfo>"
|
||||
"<sendertitle>恭喜发财,大吉大利</sendertitle>"
|
||||
"<senderdes>微信红包</senderdes>"
|
||||
"</wcpayinfo>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
voip_xml = (
|
||||
"<msg><VoIPBubbleMsg>"
|
||||
"<room_type>1</room_type>"
|
||||
"<msg>语音通话</msg>"
|
||||
"</VoIPBubbleMsg></msg>"
|
||||
)
|
||||
quote_voice_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>57</type>"
|
||||
"<title>回复语音</title>"
|
||||
"<refermsg>"
|
||||
"<type>34</type>"
|
||||
f"<svrid>{self._VOICE_SERVER_ID}</svrid>"
|
||||
"<fromusr>wxid_friend</fromusr>"
|
||||
"<displayname>测试好友</displayname>"
|
||||
"<content>wxid_friend:3000:1:</content>"
|
||||
"</refermsg>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
rows = [
|
||||
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
|
||||
(2, 1002, 1, 2, 2, 1735689602, "普通文本消息[微笑]", None),
|
||||
(3, 1003, 49, 3, 1, 1735689603, transfer_xml, None),
|
||||
(4, 1004, 49, 4, 2, 1735689604, red_packet_xml, None),
|
||||
(5, 1005, 49, 5, 1, 1735689605, file_xml, None),
|
||||
(6, 1006, 49, 6, 2, 1735689606, link_xml, None),
|
||||
(7, 1007, 49, 7, 2, 1735689607, chat_history_xml, None),
|
||||
(8, 1008, 50, 8, 2, 1735689608, voip_xml, None),
|
||||
(9, self._VOICE_SERVER_ID, 34, 9, 1, 1735689609, voice_xml, None),
|
||||
(10, 1010, 49, 10, 1, 1735689610, quote_voice_xml, None),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_media_files(self, account_dir: Path) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
(resource_root / "bb").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "bb" / f"{self._FILE_MD5}.dat").write_bytes(b"dummy")
|
||||
|
||||
conn = sqlite3.connect(str(account_dir / "media_0.db"))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE VoiceInfo (
|
||||
svr_id INTEGER,
|
||||
create_time INTEGER,
|
||||
voice_data BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO VoiceInfo VALUES (?, ?, ?)",
|
||||
(self._VOICE_SERVER_ID, 1735689609, b"SILK_VOICE_DATA"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
self._seed_media_files(account_dir)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="html",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=True,
|
||||
media_kinds=["image", "emoji", "video", "video_thumb", "voice", "file"],
|
||||
message_types=[],
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=False,
|
||||
privacy_mode=False,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def test_html_export_contains_index_and_conversation_page(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
|
||||
self.assertIn("index.html", names)
|
||||
self.assertIn("assets/wechat-chat-export.css", names)
|
||||
self.assertIn("assets/wechat-chat-export.js", names)
|
||||
|
||||
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
self.assertEqual(manifest.get("format"), "html")
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn('data-wce-rail-avatar="1"', html_text)
|
||||
self.assertIn('data-wce-session-list="1"', html_text)
|
||||
self.assertIn('id="sessionSearchInput"', html_text)
|
||||
self.assertIn('data-wce-time-divider="1"', html_text)
|
||||
self.assertIn('id="messageTypeFilter"', html_text)
|
||||
self.assertIn('value="chatHistory"', html_text)
|
||||
self.assertIn('id="chatHistoryModal"', html_text)
|
||||
self.assertIn('data-wce-chat-history="1"', html_text)
|
||||
self.assertIn('data-record-item-b64="', html_text)
|
||||
self.assertIn('id="wceMediaIndex"', html_text)
|
||||
self.assertIn('data-wce-quote-voice-btn="1"', html_text)
|
||||
self.assertNotIn('title="刷新消息"', html_text)
|
||||
self.assertNotIn('title="导出聊天记录"', html_text)
|
||||
self.assertNotIn("搜索聊天记录", html_text)
|
||||
self.assertNotIn("朋友圈", html_text)
|
||||
self.assertNotIn("年度总结", html_text)
|
||||
self.assertNotIn("设置", html_text)
|
||||
self.assertNotIn("隐私模式", html_text)
|
||||
|
||||
self.assertTrue(any(n.startswith("media/images/") for n in names))
|
||||
self.assertIn("../../media/images/", html_text)
|
||||
|
||||
self.assertIn("wechat-transfer-card", html_text)
|
||||
self.assertIn("wechat-redpacket-card", html_text)
|
||||
self.assertIn("wechat-chat-history-card", html_text)
|
||||
self.assertIn("wechat-voip-bubble", html_text)
|
||||
self.assertIn("wechat-link-card", html_text)
|
||||
self.assertIn("wechat-file-card", html_text)
|
||||
self.assertIn("wechat-voice-wrapper", html_text)
|
||||
|
||||
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
|
||||
self.assertIn("wechat-transfer-card", css_text)
|
||||
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
|
||||
|
||||
js_text = zf.read("assets/wechat-chat-export.js").decode("utf-8", errors="ignore")
|
||||
self.assertIn("wechat-voice-bubble", js_text)
|
||||
self.assertIn("voice-playing", js_text)
|
||||
self.assertIn("data-wce-quote-voice-btn", js_text)
|
||||
|
||||
self.assertIn("assets/images/wechat/wechat-trans-icon1.png", names)
|
||||
self.assertIn("assets/images/wechat/zip.png", names)
|
||||
self.assertIn("assets/images/wechat/WeChat-Icon-Logo.wine.svg", names)
|
||||
self.assertTrue(any(n.startswith("fonts/") and n.endswith(".woff2") for n in names))
|
||||
self.assertIn("wxemoji/Expression_1@2x.png", names)
|
||||
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
@@ -0,0 +1,199 @@
|
||||
import os
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportImageMd5CandidateFallback(unittest.TestCase):
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
good_md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
bad_md5 = "ffffffffffffffffffffffffffffffff"
|
||||
image_xml = f'<msg><img md5="{bad_md5}" hdmd5="{good_md5}" cdnthumburl="img_file_id_1" /></msg>'
|
||||
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_decrypted_resource(self, account_dir: Path) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
self._seed_decrypted_resource(account_dir)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="html",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=True,
|
||||
media_kinds=["image"],
|
||||
message_types=[],
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=False,
|
||||
privacy_mode=False,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def test_falls_back_to_secondary_md5_candidate(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
self.assertIn("media/images/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg", names)
|
||||
self.assertFalse(any("ffffffffffffffffffffffffffffffff" in n for n in names if n.startswith("media/images/")))
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8", errors="ignore")
|
||||
self.assertIn("../../media/images/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg", html_text)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import os
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportImageMd5PrefersMessageResource(unittest.TestCase):
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_source_info(self, account_dir: Path) -> None:
|
||||
wxid_dir = account_dir / "_wxid_dummy"
|
||||
db_storage_dir = account_dir / "_db_storage_dummy"
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
(account_dir / "_source.json").write_text(
|
||||
'{"wxid_dir": "' + str(wxid_dir).replace("\\", "\\\\") + '", "db_storage_path": "' + str(db_storage_dir).replace("\\", "\\\\") + '"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str, bad_md5: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
image_xml = f'<msg><img md5="{bad_md5}" /></msg>'
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_resource_db(self, path: Path, *, good_md5: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE MessageResourceInfo (
|
||||
message_id INTEGER,
|
||||
message_svr_id INTEGER,
|
||||
message_local_type INTEGER,
|
||||
chat_id INTEGER,
|
||||
message_local_id INTEGER,
|
||||
message_create_time INTEGER,
|
||||
packed_info BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
# packed_info may contain multiple tokens; include a realistic *.dat reference so the extractor prefers it.
|
||||
packed_info = f"{good_md5}_t.dat".encode("ascii")
|
||||
conn.execute(
|
||||
"INSERT INTO MessageResourceInfo VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(1, 1001, 3, 0, 1, 1735689601, packed_info),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_decrypted_resource(self, account_dir: Path, *, good_md5: str) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / good_md5[:2]).mkdir(parents=True, exist_ok=True)
|
||||
# Minimal JPEG payload (valid SOI/EOI).
|
||||
(resource_root / good_md5[:2] / f"{good_md5}.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str, bad_md5: str, good_md5: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_source_info(account_dir)
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username, bad_md5=bad_md5)
|
||||
self._seed_message_resource_db(account_dir / "message_resource.db", good_md5=good_md5)
|
||||
self._seed_decrypted_resource(account_dir, good_md5=good_md5)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="html",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=True,
|
||||
media_kinds=["image"],
|
||||
message_types=["image"],
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=False,
|
||||
privacy_mode=False,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def test_prefers_message_resource_md5_over_xml_md5(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
bad_md5 = "ffffffffffffffffffffffffffffffff"
|
||||
good_md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
self._prepare_account(root, account=account, username=username, bad_md5=bad_md5, good_md5=good_md5)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
self.assertIn(f"media/images/{good_md5}.jpg", names)
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8", errors="ignore")
|
||||
self.assertIn(f"../../media/images/{good_md5}.jpg", html_text)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
@@ -198,6 +198,7 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
message_types=message_types,
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=False,
|
||||
privacy_mode=privacy_mode,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import os
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body: bytes, *, content_type: str) -> None:
|
||||
self.status_code = 200
|
||||
self.headers = {
|
||||
"Content-Type": str(content_type or "").strip(),
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
self._body = body
|
||||
|
||||
def iter_content(self, chunk_size=65536):
|
||||
data = self._body or b""
|
||||
for i in range(0, len(data), int(chunk_size or 65536)):
|
||||
yield data[i : i + int(chunk_size or 65536)]
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class TestChatExportRemoteThumbOption(unittest.TestCase):
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> tuple[str, str]:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
link_thumb = "https://1.1.1.1/thumb.png"
|
||||
quote_thumb = "https://1.1.1.1/quote.png"
|
||||
|
||||
link_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>5</type>"
|
||||
"<title>示例链接</title>"
|
||||
"<des>这是描述</des>"
|
||||
"<url>https://example.com/</url>"
|
||||
f"<thumburl>{link_thumb}</thumburl>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
quote_xml = (
|
||||
"<msg><appmsg>"
|
||||
"<type>57</type>"
|
||||
"<title>回复</title>"
|
||||
"<refermsg>"
|
||||
"<type>49</type>"
|
||||
"<svrid>8888</svrid>"
|
||||
"<fromusr>wxid_other</fromusr>"
|
||||
"<displayname>对方</displayname>"
|
||||
"<content>"
|
||||
"<msg><appmsg><type>5</type><title>被引用链接</title><url>https://example.com/</url>"
|
||||
f"<thumburl>{quote_thumb}</thumburl>"
|
||||
"</appmsg></msg>"
|
||||
"</content>"
|
||||
"</refermsg>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
|
||||
rows = [
|
||||
(1, 1001, 49, 1, 2, 1735689601, link_xml, None),
|
||||
(2, 1002, 49, 2, 2, 1735689602, quote_xml, None),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
return link_thumb, quote_thumb
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> tuple[Path, str, str]:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
link_thumb, quote_thumb = self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
return account_dir, link_thumb, quote_thumb
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str, download_remote_media: bool):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="html",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=True,
|
||||
media_kinds=["image", "emoji", "video", "video_thumb", "voice", "file"],
|
||||
message_types=["link", "quote", "image"],
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
download_remote_media=download_remote_media,
|
||||
privacy_mode=False,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def test_remote_thumb_disabled_does_not_download(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
_, link_thumb, quote_thumb = self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
|
||||
with mock.patch.object(
|
||||
svc.requests,
|
||||
"get",
|
||||
side_effect=AssertionError("requests.get should not be called when download_remote_media=False"),
|
||||
) as m_get:
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
download_remote_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
self.assertEqual(m_get.call_count, 0)
|
||||
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn(f'src="{link_thumb}"', html_text)
|
||||
self.assertIn(f'src="{quote_thumb}"', html_text)
|
||||
self.assertFalse(any(n.startswith("media/remote/") for n in names))
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_remote_thumb_enabled_downloads_and_rewrites(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
_, link_thumb, quote_thumb = self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
|
||||
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||||
|
||||
def _fake_get(url, **_kwargs):
|
||||
return _FakeResponse(fake_png, content_type="image/png")
|
||||
|
||||
with mock.patch.object(svc.requests, "get", side_effect=_fake_get) as m_get:
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
download_remote_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
self.assertGreaterEqual(m_get.call_count, 1)
|
||||
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
|
||||
h1 = hashlib.sha256(link_thumb.encode("utf-8", errors="ignore")).hexdigest()
|
||||
arc1 = f"media/remote/{h1[:32]}.png"
|
||||
self.assertIn(arc1, names)
|
||||
self.assertIn(f"../../{arc1}", html_text)
|
||||
self.assertNotIn(f'src="{link_thumb}"', html_text)
|
||||
|
||||
h2 = hashlib.sha256(quote_thumb.encode("utf-8", errors="ignore")).hexdigest()
|
||||
arc2 = f"media/remote/{h2[:32]}.png"
|
||||
self.assertIn(arc2, names)
|
||||
self.assertIn(f"../../{arc2}", html_text)
|
||||
self.assertNotIn(f'src="{quote_thumb}"', html_text)
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 TestChatOfficialArticleCoverStyle(unittest.TestCase):
|
||||
def test_mp_weixin_feed_url_is_cover_style(self):
|
||||
raw_text = (
|
||||
"<msg>"
|
||||
"<appmsg>"
|
||||
"<title>时尚穿搭:「这样的jk你喜欢吗」</title>"
|
||||
"<des>这样的jk你喜欢吗?</des>"
|
||||
"<type>5</type>"
|
||||
"<url>"
|
||||
"http://mp.weixin.qq.com/s?__biz=MzkxOTY4MjIxOA==&mid=2247508015&idx=1&sn=931dce677c6e70b4365792b14e7e8ff0"
|
||||
"&exptype=masonry_feed_brief_content_elite_for_pcfeeds_u2i&ranksessionid=1770868256_1&req_id=1770867949535989#rd"
|
||||
"</url>"
|
||||
"<thumburl>https://mmbiz.qpic.cn/sz_mmbiz_jpg/foo/640?wx_fmt=jpeg&wxfrom=401</thumburl>"
|
||||
"<sourcedisplayname>甜图社</sourcedisplayname>"
|
||||
"<sourceusername>gh_abc123</sourceusername>"
|
||||
"</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_mp_weixin_non_feed_url_keeps_default_style(self):
|
||||
raw_text = (
|
||||
"<msg>"
|
||||
"<appmsg>"
|
||||
"<title>普通分享</title>"
|
||||
"<des>这样的jk你喜欢吗?</des>"
|
||||
"<type>5</type>"
|
||||
"<url>http://mp.weixin.qq.com/s?__biz=foo&mid=1&idx=1&sn=bar#rd</url>"
|
||||
"<sourcedisplayname>甜图社</sourcedisplayname>"
|
||||
"<sourceusername>gh_abc123</sourceusername>"
|
||||
"</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"), "default")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,129 @@
|
||||
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"), "已被接收")
|
||||
|
||||
def test_pending_transfer_marked_expired_by_system_message(self):
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:100",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "1",
|
||||
"transferId": "t-expired-1",
|
||||
"amount": "¥500.00",
|
||||
"createTime": 1770742598,
|
||||
"isSent": True,
|
||||
"transferStatus": "转账",
|
||||
},
|
||||
{
|
||||
"id": "message_0:Msg_x:101",
|
||||
"renderType": "system",
|
||||
"type": 10000,
|
||||
"createTime": 1770829000,
|
||||
"content": "收款方24小时内未接收你的转账,已过期",
|
||||
},
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("paySubType"), "10")
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已过期")
|
||||
|
||||
def test_expired_matching_wins_over_amount_time_received_fallback(self):
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:200",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "1",
|
||||
"transferId": "t-expired-2",
|
||||
"amount": "¥500.00",
|
||||
"createTime": 1770742598,
|
||||
"isSent": True,
|
||||
"transferStatus": "",
|
||||
},
|
||||
{
|
||||
"id": "message_0:Msg_x:201",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "3",
|
||||
"transferId": "t-other",
|
||||
"amount": "¥500.00",
|
||||
"createTime": 1770828800,
|
||||
"isSent": False,
|
||||
"transferStatus": "已收款",
|
||||
},
|
||||
{
|
||||
"id": "message_0:Msg_x:202",
|
||||
"renderType": "system",
|
||||
"type": 10000,
|
||||
"createTime": 1770829000,
|
||||
"content": "收款方24小时内未接收你的转账,已过期",
|
||||
},
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("paySubType"), "10")
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已过期")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,773 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWrappedEmojiUniverse(unittest.TestCase):
|
||||
def _ts(self, y: int, m: int, d: int, h: int = 0, mi: int = 0, s: int = 0) -> int:
|
||||
return int(datetime(y, m, d, h, mi, s).timestamp())
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, usernames: list[str]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
for idx, username in enumerate(usernames):
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", f"好友{idx + 1}", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, usernames: list[str]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username in usernames:
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
account: str,
|
||||
username: str,
|
||||
rows: list[dict[str, object]],
|
||||
) -> None:
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB,
|
||||
packed_info_data BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {table_name}
|
||||
(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(row.get("local_id", 0)),
|
||||
int(row.get("server_id", 0)),
|
||||
int(row.get("local_type", 0)),
|
||||
int(row.get("sort_seq", row.get("local_id", 0))),
|
||||
int(row.get("real_sender_id", 1)),
|
||||
int(row.get("create_time", 0)),
|
||||
str(row.get("message_content", "")),
|
||||
row.get("compress_content"),
|
||||
row.get("packed_info_data"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_index_db(self, path: Path, *, rows: list[dict[str, object]]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE message_fts (
|
||||
text TEXT,
|
||||
username TEXT,
|
||||
render_type TEXT,
|
||||
create_time INTEGER,
|
||||
sort_seq INTEGER,
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
db_stem TEXT,
|
||||
table_name TEXT,
|
||||
sender_username TEXT,
|
||||
is_hidden INTEGER,
|
||||
is_official INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO message_fts (
|
||||
text, username, render_type, create_time, sort_seq, local_id, server_id, local_type,
|
||||
db_stem, table_name, sender_username, is_hidden, is_official
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(row.get("text", "")),
|
||||
str(row.get("username", "")),
|
||||
str(row.get("render_type", "")),
|
||||
int(row.get("create_time", 0)),
|
||||
int(row.get("sort_seq", 0)),
|
||||
int(row.get("local_id", 0)),
|
||||
int(row.get("server_id", 0)),
|
||||
int(row.get("local_type", 0)),
|
||||
str(row.get("db_stem", "message_0")),
|
||||
str(row.get("table_name", "")),
|
||||
str(row.get("sender_username", "")),
|
||||
int(row.get("is_hidden", 0)),
|
||||
int(row.get("is_official", 0)),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_resource_db(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
username: str,
|
||||
md5: str,
|
||||
server_id: int,
|
||||
local_id: int,
|
||||
create_time: int,
|
||||
) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE ChatName2Id (user_name TEXT)")
|
||||
conn.execute("INSERT INTO ChatName2Id (rowid, user_name) VALUES (?, ?)", (7, username))
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE MessageResourceInfo (
|
||||
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_svr_id INTEGER,
|
||||
chat_id INTEGER,
|
||||
message_local_type INTEGER,
|
||||
packed_info BLOB,
|
||||
message_local_id INTEGER,
|
||||
message_create_time INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
packed = f"/tmp/{md5}.dat".encode("utf-8")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO MessageResourceInfo
|
||||
(message_svr_id, chat_id, message_local_type, packed_info, message_local_id, message_create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(int(server_id), 7, 47, packed, int(local_id), int(create_time)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_only_sticker_messages_outputs_core_stats(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_a"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
md5_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
md5_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
rows = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"server_id": 1001,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 1, 1, 10, 5, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a.gif"/></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"server_id": 1002,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 1, 1, 10, 30, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a2.gif"/></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 3,
|
||||
"server_id": 1003,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 1, 2, 22, 10, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_b}" cdnurl="http://cdn/b.gif"/></msg>',
|
||||
},
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||
fts_rows = []
|
||||
for row in rows:
|
||||
fts_rows.append(
|
||||
{
|
||||
"text": "[表情]",
|
||||
"username": friend,
|
||||
"render_type": "emoji",
|
||||
"create_time": row["create_time"],
|
||||
"sort_seq": row["local_id"],
|
||||
"local_id": row["local_id"],
|
||||
"server_id": row["server_id"],
|
||||
"local_type": 47,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
}
|
||||
)
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertTrue(data["settings"]["usedIndex"])
|
||||
self.assertEqual(data["sentStickerCount"], 3)
|
||||
self.assertEqual(data["peakHour"], 10)
|
||||
self.assertIsNotNone(data["peakWeekday"])
|
||||
self.assertEqual(data["topBattlePartner"]["username"], friend)
|
||||
self.assertEqual(data["topBattlePartner"]["stickerCount"], 3)
|
||||
self.assertEqual(data["topBattlePartner"]["maskedName"], data["topBattlePartner"]["displayName"])
|
||||
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
|
||||
self.assertEqual(data["topStickers"][0]["count"], 2)
|
||||
self.assertTrue(str(data["topStickers"][0].get("sampleDisplayName") or "").strip())
|
||||
self.assertTrue(str(data["topStickers"][0].get("sampleAvatarUrl") or "").startswith("/api/chat/avatar"))
|
||||
|
||||
def test_fallback_to_resource_md5_when_xml_missing(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_b"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
ts = self._ts(2025, 3, 8, 21, 0, 0)
|
||||
rows = [
|
||||
{
|
||||
"local_id": 11,
|
||||
"server_id": 220011,
|
||||
"local_type": 47,
|
||||
"create_time": ts,
|
||||
"message_content": '<msg><emoji cdnurl="http://cdn/no_md5.gif"/></msg>',
|
||||
}
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
md5_fallback = "cccccccccccccccccccccccccccccccc"
|
||||
self._seed_resource_db(
|
||||
account_dir / "message_resource.db",
|
||||
username=friend,
|
||||
md5=md5_fallback,
|
||||
server_id=220011,
|
||||
local_id=11,
|
||||
create_time=ts,
|
||||
)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertFalse(data["settings"]["usedIndex"])
|
||||
self.assertEqual(data["sentStickerCount"], 1)
|
||||
self.assertEqual(data["topStickers"][0]["md5"], md5_fallback)
|
||||
self.assertEqual(data["topStickers"][0]["count"], 1)
|
||||
|
||||
def test_text_emoji_mapping_from_wechat_emojis_ts(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_c"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||
fts_rows = [
|
||||
{
|
||||
"text": "早上好[微笑][微笑]🙂🙂",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 4, 1, 9, 0, 0),
|
||||
"local_id": 1,
|
||||
"server_id": 901,
|
||||
"local_type": 1,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
},
|
||||
{
|
||||
"text": "晚上见[微笑][发呆]😂",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 4, 1, 22, 0, 0),
|
||||
"local_id": 2,
|
||||
"server_id": 902,
|
||||
"local_type": 1,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
},
|
||||
]
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
self.assertTrue(data["settings"]["usedIndex"])
|
||||
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
|
||||
self.assertEqual(data["topTextEmojis"][0]["key"], "[微笑]")
|
||||
self.assertEqual(data["topTextEmojis"][0]["count"], 3)
|
||||
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_1@2x.png"))
|
||||
self.assertGreaterEqual(len(data["topUnicodeEmojis"]), 1)
|
||||
self.assertEqual(data["topUnicodeEmojis"][0]["emoji"], "🙂")
|
||||
self.assertEqual(data["topUnicodeEmojis"][0]["count"], 2)
|
||||
|
||||
def test_wechat_builtin_emoji_from_packed_info_data(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_e"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
# packed_info_data protobuf varints:
|
||||
# 08 04 => field#1=4
|
||||
# 10 33 => field#2=51 (Expression_51@2x)
|
||||
rows = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"server_id": 501,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 7, 1, 10, 0, 0),
|
||||
"message_content": "binary_emoji_payload_a",
|
||||
"packed_info_data": bytes.fromhex("08041033"),
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"server_id": 502,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 7, 1, 10, 1, 0),
|
||||
"message_content": "binary_emoji_payload_b",
|
||||
"packed_info_data": bytes.fromhex("08041033"),
|
||||
},
|
||||
{
|
||||
"local_id": 3,
|
||||
"server_id": 503,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 7, 1, 11, 0, 0),
|
||||
"message_content": "binary_emoji_payload_c",
|
||||
"packed_info_data": bytes.fromhex("0804104a"),
|
||||
},
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertFalse(data["settings"]["usedIndex"])
|
||||
self.assertEqual(data["sentStickerCount"], 3)
|
||||
self.assertGreaterEqual(len(data["topWechatEmojis"]), 1)
|
||||
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
|
||||
self.assertEqual(data["topWechatEmojis"][0]["count"], 2)
|
||||
self.assertTrue(data["topWechatEmojis"][0]["assetPath"].endswith("Expression_51@2x.png"))
|
||||
self.assertGreaterEqual(len(data["topStickers"]), 1)
|
||||
self.assertEqual(data["topStickers"][0]["emojiId"], 51)
|
||||
self.assertEqual(data["topStickers"][0]["count"], 2)
|
||||
self.assertTrue(str(data["topStickers"][0].get("emojiAssetPath") or "").endswith("Expression_51@2x.png"))
|
||||
|
||||
def test_index_counts_only_sent_messages(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_sent_only"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
rows = [
|
||||
{
|
||||
"text": "[ 微 笑 ]",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
|
||||
"local_id": 101,
|
||||
"server_id": 4001,
|
||||
"local_type": 1,
|
||||
"table_name": "msg_dummy",
|
||||
"sender_username": account,
|
||||
},
|
||||
{
|
||||
"text": "[ 发 呆 ]",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
|
||||
"local_id": 102,
|
||||
"server_id": 4002,
|
||||
"local_type": 1,
|
||||
"table_name": "msg_dummy",
|
||||
"sender_username": friend,
|
||||
},
|
||||
{
|
||||
"text": "[表情]",
|
||||
"username": friend,
|
||||
"render_type": "emoji",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 2, 0),
|
||||
"local_id": 201,
|
||||
"server_id": 5001,
|
||||
"local_type": 47,
|
||||
"table_name": "msg_dummy",
|
||||
"sender_username": account,
|
||||
},
|
||||
{
|
||||
"text": "[表情]",
|
||||
"username": friend,
|
||||
"render_type": "emoji",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 3, 0),
|
||||
"local_id": 202,
|
||||
"server_id": 5002,
|
||||
"local_type": 47,
|
||||
"table_name": "msg_dummy",
|
||||
"sender_username": friend,
|
||||
},
|
||||
]
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows=rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
self.assertTrue(data["settings"]["usedIndex"])
|
||||
|
||||
self.assertEqual(data["sentStickerCount"], 1)
|
||||
|
||||
keys = {x.get("key") for x in data.get("topTextEmojis") or []}
|
||||
self.assertIn("[微笑]", keys)
|
||||
self.assertNotIn("[发呆]", keys)
|
||||
|
||||
def test_raw_db_counts_only_sent_messages(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_raw_dir"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
rows = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"server_id": 1001,
|
||||
"local_type": 1,
|
||||
"real_sender_id": 1,
|
||||
"create_time": self._ts(2025, 7, 1, 8, 0, 0),
|
||||
"message_content": "/::B",
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"server_id": 1002,
|
||||
"local_type": 1,
|
||||
"real_sender_id": 2,
|
||||
"create_time": self._ts(2025, 7, 1, 8, 1, 0),
|
||||
"message_content": "/::B",
|
||||
},
|
||||
{
|
||||
"local_id": 3,
|
||||
"server_id": 1101,
|
||||
"local_type": 47,
|
||||
"real_sender_id": 1,
|
||||
"create_time": self._ts(2025, 7, 1, 9, 0, 0),
|
||||
"message_content": "binary_emoji_payload_a",
|
||||
"packed_info_data": bytes.fromhex("08031033"),
|
||||
},
|
||||
{
|
||||
"local_id": 4,
|
||||
"server_id": 1102,
|
||||
"local_type": 47,
|
||||
"real_sender_id": 2,
|
||||
"create_time": self._ts(2025, 7, 1, 9, 1, 0),
|
||||
"message_content": "binary_emoji_payload_b",
|
||||
"packed_info_data": bytes.fromhex("08031033"),
|
||||
},
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertFalse(data["settings"]["usedIndex"])
|
||||
self.assertEqual(data["sentStickerCount"], 1)
|
||||
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
|
||||
self.assertEqual(data["topWechatEmojis"][0]["count"], 1)
|
||||
|
||||
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
|
||||
self.assertEqual(data["topTextEmojis"][0]["key"], "[色]")
|
||||
self.assertEqual(data["topTextEmojis"][0]["count"], 1)
|
||||
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_3@2x.png"))
|
||||
|
||||
def test_new_and_revived_sticker_metrics(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_new_revived"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
md5_revived = "dddddddddddddddddddddddddddddddd"
|
||||
md5_recent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
md5_new = "ffffffffffffffffffffffffffffffff"
|
||||
rows = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"server_id": 5001,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2024, 1, 1, 9, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"server_id": 5002,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2024, 12, 28, 10, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 3,
|
||||
"server_id": 5003,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 1, 5, 11, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 4,
|
||||
"server_id": 5004,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 3, 15, 12, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 5,
|
||||
"server_id": 5005,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 5, 10, 13, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_new}" /></msg>',
|
||||
},
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertEqual(data["sentStickerCount"], 3)
|
||||
self.assertEqual(data["uniqueStickerTypeCount"], 3)
|
||||
self.assertEqual(data["newStickerCountThisYear"], 1)
|
||||
self.assertEqual(data["revivedStickerCount"], 1)
|
||||
self.assertEqual(data["revivedMinGapDays"], 60)
|
||||
self.assertGreaterEqual(int(data.get("revivedMaxGapDays") or 0), 400)
|
||||
new_samples = list(data.get("newStickerSamples") or [])
|
||||
revived_samples = list(data.get("revivedStickerSamples") or [])
|
||||
self.assertTrue(any(str(x.get("md5") or "") == md5_new for x in new_samples))
|
||||
self.assertTrue(any(str(x.get("md5") or "") == md5_revived for x in revived_samples))
|
||||
revived_item = next((x for x in revived_samples if str(x.get("md5") or "") == md5_revived), {})
|
||||
self.assertGreaterEqual(int(revived_item.get("gapDays") or 0), 400)
|
||||
|
||||
def test_empty_year_returns_safe_empty_state(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[])
|
||||
|
||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=2025)
|
||||
self.assertEqual(card["id"], 4)
|
||||
self.assertEqual(card["status"], "ok")
|
||||
self.assertEqual(card["data"]["sentStickerCount"], 0)
|
||||
self.assertIn("几乎没用表情表达", card["narrative"])
|
||||
self.assertIsInstance(card["data"]["lines"], list)
|
||||
self.assertGreaterEqual(len(card["data"]["lines"]), 1)
|
||||
self.assertEqual(card["data"].get("topUnicodeEmojis"), [])
|
||||
|
||||
def test_tie_break_is_stable_by_key(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_me"
|
||||
friend = "wxid_friend_d"
|
||||
account_dir = root / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||
|
||||
md5_a = "11111111111111111111111111111111"
|
||||
md5_b = "22222222222222222222222222222222"
|
||||
rows = [
|
||||
{
|
||||
"local_id": 1,
|
||||
"server_id": 301,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 6, 1, 8, 0, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 2,
|
||||
"server_id": 302,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 6, 1, 8, 1, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 3,
|
||||
"server_id": 303,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 6, 1, 8, 2, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
|
||||
},
|
||||
{
|
||||
"local_id": 4,
|
||||
"server_id": 304,
|
||||
"local_type": 47,
|
||||
"create_time": self._ts(2025, 6, 1, 8, 3, 0),
|
||||
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
|
||||
},
|
||||
]
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||
|
||||
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||
fts_rows = []
|
||||
for row in rows:
|
||||
fts_rows.append(
|
||||
{
|
||||
"text": "[表情]",
|
||||
"username": friend,
|
||||
"render_type": "emoji",
|
||||
"create_time": row["create_time"],
|
||||
"local_id": row["local_id"],
|
||||
"server_id": row["server_id"],
|
||||
"local_type": 47,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
}
|
||||
)
|
||||
fts_rows.extend(
|
||||
[
|
||||
{
|
||||
# `chat_search_index` stores text as char-tokens: "[微笑][发呆]" -> "[ 微 笑 ] [ 发 呆 ]"
|
||||
"text": "[ 微 笑 ] [ 发 呆 ]",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
|
||||
"local_id": 101,
|
||||
"server_id": 4001,
|
||||
"local_type": 1,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
},
|
||||
{
|
||||
"text": "[ 发 呆 ] [ 微 笑 ]",
|
||||
"username": friend,
|
||||
"render_type": "text",
|
||||
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
|
||||
"local_id": 102,
|
||||
"server_id": 4002,
|
||||
"local_type": 1,
|
||||
"db_stem": "message_0",
|
||||
"table_name": table_name,
|
||||
"sender_username": account,
|
||||
},
|
||||
]
|
||||
)
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||
|
||||
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||
|
||||
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
|
||||
expected_emoji_key = sorted(["[微笑]", "[发呆]"])[0]
|
||||
self.assertEqual(data["topTextEmojis"][0]["key"], expected_emoji_key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user