Files
WechatExplorer/src/main/services/bootstrap-cache.ts
T

247 lines
7.6 KiB
TypeScript

import { app } from 'electron'
import crypto from 'crypto'
import fs from 'fs-extra'
import path from 'path'
import type { Contact, Message } from '../../shared/types'
export interface CachedSelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface CachedMessageBucket {
updatedAt: number
startTime?: number
endTime?: number
items: Message[]
}
interface BootstrapCacheFile {
version: 1
platform: NodeJS.Platform
accountRoot: string
updatedAt: number
self?: CachedSelfInfo
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
}
const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24
const MAX_MESSAGES_PER_BUCKET = 1200
function normalizeRoot(accountRoot?: string): string {
return String(accountRoot || '').trim()
}
function getCacheFile(accountRoot?: string): string {
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
const hash = crypto
.createHash('sha1')
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.slice(0, 16)
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
}
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getCacheFile(normalizedRoot)
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
return {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
}
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
}
}
function writeCacheFile(cache: BootstrapCacheFile): void {
try {
const file = getCacheFile(cache.accountRoot)
fs.ensureDirSync(path.dirname(file))
fs.writeJsonSync(file, cache, { spaces: 2 })
} catch (error) {
console.warn('[BootstrapCache] write failed:', error)
}
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
return (
readCacheFile(normalizedRoot) || {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
}
)
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
}
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
entries
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
.slice(MAX_MESSAGE_BUCKETS)
.forEach(([key]) => {
delete messages[key]
})
}
export function getBootstrapCache(accountRoot?: string): {
self?: CachedSelfInfo
contacts: Contact[]
updatedAt: number
} | null {
const cache = readCacheFile(accountRoot)
if (!cache) return null
return {
self: cache.self,
contacts: cache.contacts || [],
updatedAt: cache.updatedAt
}
}
function isRawContactName(contact: Contact): boolean {
const name = String(contact.m_nsNickName || '').trim()
const username = String(contact.m_nsUsrName || '').trim()
if (!name) return true
if (name === username) return true
if (name.endsWith('@chatroom')) return true
if (name.startsWith('Group_') || name.startsWith('Unknown_')) return true
return false
}
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
const cache = readCacheFile(accountRoot)
if (!cache?.contacts?.length) return contacts
const avatarByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
)
const nameByUsername = new Map(
cache.contacts
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
return contacts.map((contact) => ({
...contact,
avatar:
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
? contact.avatar
: avatarByUsername.get(contact.m_nsUsrName),
m_nsNickName:
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
? contact.m_nsNickName
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
}))
}
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
cache.self = self
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
const avatarByUsername = new Map(
(cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
)
const nameByUsername = new Map(
(cache.contacts || [])
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
)
cache.contacts = contacts.map((contact) => ({
...contact,
avatar:
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
? contact.avatar
: avatarByUsername.get(contact.m_nsUsrName),
m_nsNickName:
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
? contact.m_nsNickName
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
}))
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
const cache = loadOrCreate(accountRoot)
if (!cache || !cache.contacts?.length) return
let changed = false
cache.contacts = cache.contacts.map((contact) => {
const avatar = avatars[contact.m_nsUsrName]
if (!avatar || contact.avatar === avatar) return contact
changed = true
return { ...contact, avatar }
})
if (!changed) return
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function getCachedMessages(
accountRoot: string,
userMd5: string,
startTime?: number,
endTime?: number
): Message[] {
const cache = readCacheFile(accountRoot)
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
return bucket?.items || []
}
export function saveCachedMessages(
accountRoot: string,
userMd5: string,
startTime: number | undefined,
endTime: number | undefined,
messages: Message[]
): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
const nextMessages = cache.messages || {}
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
updatedAt: Date.now(),
startTime,
endTime,
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
}
pruneMessageBuckets(nextMessages)
cache.messages = nextMessages
cache.updatedAt = Date.now()
writeCacheFile(cache)
}