import fs from 'fs-extra' import path from 'path' import os from 'os' import crypto from 'crypto' import { createRequire } from 'module' import { createConnection, Socket } from 'net' import { getResourceRoots } from './resource-paths' export interface Wcdb4Session { username: string nickname: string avatar?: string wechatNickname?: string remark?: string isFolded?: boolean isMuted?: boolean raw: Record } export interface Wcdb4Message { mesLocalID: string serverId?: string mesDes: number messageType: string msgCreateTime: string msgContent: string sender?: string senderNickname?: string senderAvatar?: string raw: Record } export interface Wcdb4MessageQueryOptions { limit?: number } export interface Wcdb4SessionQueryOptions { hydrateDisplayNames?: boolean hydrateStatuses?: boolean } type Wcdb4MessageStore = { tableName: string dbPath: string } export interface Wcdb4GroupMember { m_nsUsrName: string nickname: string groupNickname: string wechatNickname: string remark: string m_nsHeadImgUrl: string } export interface Wcdb4ImageHardlink { file_name?: string full_path?: string [key: string]: unknown } export interface Wcdb4VideoHardlink { resolved_md5?: string [key: string]: unknown } type KoffiModule = { load: (libraryPath: string) => KoffiLibrary decode: (ptr: unknown, type: string, length: number) => string } // Module-level singleton for WCDB native handle. WCDB's Windows runtime // returns -1006 if wcdb_init is called more than once per process, so we // perform InitProtection + wcdb_init exactly once and cache the resulting // library reference for every Wcdb4Client instance. let wcdbBootstrapLib: KoffiLibrary | null = null let wcdbBootstrapAsyncPromise: Promise | null = null export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary { if (wcdbBootstrapLib) return wcdbBootstrapLib const koffi = nodeRequire('koffi') as KoffiModule const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary() const libDir = libDirOverride || path.dirname(resolvedLibPath) console.log( `[WCDB4] bootstrap koffi.load begin ${resolvedLibPath} cwd=${process.cwd()} resourcesPath=${process.resourcesPath || ''}` ) for (const name of process.platform === 'win32' ? ['WCDB.dll', 'SDL2.dll'] : process.platform === 'darwin' ? ['libWCDB.dylib'] : []) { const preloadPath = path.join(libDir, name) if (!fs.existsSync(preloadPath)) continue try { koffi.load(preloadPath) console.log(`[WCDB4] bootstrap preload ok ${preloadPath}`) } catch { console.warn(`[WCDB4] bootstrap preload failed ${preloadPath}`) } } const lib = koffi.load(resolvedLibPath) console.log(`[WCDB4] bootstrap koffi.load ok ${resolvedLibPath}`) const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as ( resourcePath: string ) => number const resourceRoots = Array.from( new Set( [ libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots() ].filter(Boolean) ) ) let lastCode = -1 let initOk = false for (const resourceRoot of resourceRoots) { try { console.log(`[WCDB4] bootstrap InitProtection call ${resourceRoot}`) lastCode = Number(initProtection(resourceRoot)) console.log(`[WCDB4] bootstrap InitProtection rc=${lastCode} path=${resourceRoot}`) if (lastCode === 0) { initOk = true console.log(`[WCDB4] bootstrap InitProtection ok path=${resourceRoot}`) break } } catch (error) { console.warn(`[WCDB4] bootstrap InitProtection exception path=${resourceRoot}`, error) } } if (!initOk) { console.warn( `[WCDB4] bootstrap InitProtection 返回 ${lastCode},继续尝试 wcdb_init/open; tried=${resourceRoots.join(' | ')}` ) } else { const wcdbInit = lib.func('int32 wcdb_init()') as () => number console.log('[WCDB4] bootstrap wcdb_init begin') const initRc = Number(wcdbInit()) console.log(`[WCDB4] bootstrap wcdb_init rc=${initRc}`) if (initRc !== 0) { console.warn(`[WCDB4] bootstrap wcdb_init 返回 ${initRc},继续尝试 wcdb_open_account`) } } wcdbBootstrapLib = lib return lib } export function bootstrapWcdbNativeAsync( libPath?: string, libDirOverride?: string ): Promise { if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib) if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise wcdbBootstrapAsyncPromise = (async () => { const koffi = nodeRequire('koffi') as KoffiModule const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary() const libDir = libDirOverride || path.dirname(resolvedLibPath) for (const name of process.platform === 'win32' ? ['WCDB.dll', 'SDL2.dll'] : process.platform === 'darwin' ? ['libWCDB.dylib'] : []) { const preloadPath = path.join(libDir, name) if (!fs.existsSync(preloadPath)) continue try { koffi.load(preloadPath) } catch { // The main library may still resolve its dependencies through the loader. } } const lib = koffi.load(resolvedLibPath) const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as ( resourcePath: string ) => number const resourceRoots = Array.from( new Set( [ libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots() ].filter(Boolean) ) ) let initOk = false for (const resourceRoot of resourceRoots) { try { if (Number(initProtection(resourceRoot)) === 0) { initOk = true break } } catch { // Try the next resource root. } } if (initOk) { const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction await new Promise((resolve, reject) => { wcdbInit.async((error: unknown, result: unknown) => { if (error) { reject(error) return } if (Number(result) !== 0) { console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`) } resolve() }) }) } wcdbBootstrapLib = lib return lib })().catch((error) => { wcdbBootstrapAsyncPromise = null throw error }) return wcdbBootstrapAsyncPromise } type KoffiLibrary = { func: (signature: string) => KoffiAsyncFunction } type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & { async: (...args: unknown[]) => void } type WcdbVoidOut = [unknown] type WcdbHandleOut = [number] const nodeRequire = createRequire(import.meta.url) export class Wcdb4Client { static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot() private readonly key: string private readonly accountRoot: string private readonly wxid: string private readonly dbStoragePath: string private readonly sessionDbPath: string private koffi: KoffiModule | null = null private handle: number | null = null private displayNameCache = new Map() private avatarCache = new Map() private sessionStatusCache = new Map() private groupNicknameCache = new Map>() private cachedSessions: Wcdb4Session[] | null = null private cachedChatTables: { name: string; db_number: string }[] | null = null private sessionsInFlight: Promise | null = null private sessionDisplayNamesInFlight: Promise | null = null private sessionDisplayNamesHydrated = false private sessionStatusesInFlight: Promise | null = null private sessionStatusesUpdatedAt = 0 private sessionCacheGeneration = 0 private closing = false private nativeCallsInFlight = new Set>() private wcdbShutdown: (() => number) | null = null private wcdbOpenAccount: | ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number) | null = null private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null private wcdbFreeString: ((ptr: unknown) => void) | null = null private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null private wcdbGetMessages: | (( handle: number, username: string, limit: number, offset: number, outJson: WcdbVoidOut ) => number) | null = null private wcdbGetMessageTableStats: | ((handle: number, username: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetDisplayNames: | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetAvatarUrls: | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetContactStatus: | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetHeadImageBuffers: | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | null = null private wcdbExecQuery: | ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetGroupMembers: | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetGroupNicknames: | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | null = null private wcdbOpenMessageCursor: | (( handle: number, username: string, batchSize: number, ascending: number, beginTimestamp: number, endTimestamp: number, cursorOut: WcdbHandleOut ) => number) | null = null private wcdbFetchMessageBatch: | ((handle: number, cursor: number, outJson: WcdbVoidOut, outHasMore: [number]) => number) | null = null private wcdbCloseMessageCursor: ((handle: number, cursor: number) => number) | null = null private wcdbGetVoiceData: | (( handle: number, sessionId: string, createTime: number, localId: number, svrId: bigint, candidatesJson: string, outHex: WcdbVoidOut ) => number) | null = null private wcdbResolveImageHardlink: | ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number) | null = null private wcdbResolveVideoHardlink: | ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number) | null = null private wcdbGetEmoticonCdnUrl: | ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number) | null = null private wcdbStartMonitorPipe: (() => number) | null = null private wcdbStopMonitorPipe: (() => void) | null = null private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null private monitorPipeClient: Socket | null = null private monitorCallback: ((type: string, json: string) => void) | null = null private monitorConnectTimer: ReturnType | null = null private monitorReconnectTimer: ReturnType | null = null private monitorPipePath = '' private monitorStarted = false constructor(key: string, accountRoot?: string) { this.key = key.replace(/^0x/i, '').trim() this.accountRoot = accountRoot ? Wcdb4Client.resolveAccountRoot(accountRoot) : Wcdb4Client.findLatestAccountRoot() this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot)) this.dbStoragePath = path.join(this.accountRoot, 'db_storage') this.sessionDbPath = this.findSessionDb() if (!this.sessionDbPath) { throw new Error(`未找到微信 4.0 session.db: ${this.dbStoragePath}`) } } static resolveAccountRoot(accountRoot: string): string { const target = (accountRoot || '').trim().replace(/[\\/]+$/, '') if (!target) { throw new Error('微信 4.0 账号目录不能为空') } if (Wcdb4Client.hasDbStorage(target)) { return target } if (!fs.existsSync(target)) { throw new Error(`未找到微信 4.0 数据目录: ${target}`) } const candidates = fs .readdirSync(target) .map((name) => path.join(target, name)) .filter((candidate) => Wcdb4Client.hasDbStorage(candidate)) .sort(Wcdb4Client.compareAccountDirs) if (!candidates[0]) { throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${target}`) } return candidates[0] } static findLatestAccountRoot(): string { const root = Wcdb4Client.defaultRoot if (!fs.existsSync(root)) { throw new Error(`未找到微信 4.0 数据目录: ${root}`) } if (Wcdb4Client.hasDbStorage(root)) { return root } const candidates = fs .readdirSync(root) .map((name) => path.join(root, name)) .filter((candidate) => Wcdb4Client.hasDbStorage(candidate)) .sort(Wcdb4Client.compareAccountDirs) if (!candidates[0]) { throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${root}`) } return candidates[0] } private static findExistingDefaultRoot(): string { const roots = Wcdb4Client.getDefaultRootCandidates() return roots.find((root) => Wcdb4Client.isUsableRoot(root)) || roots[0] } private static getDefaultRootCandidates(): string[] { const home = os.homedir() if (process.platform === 'win32') { // 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选 const candidates = [ ...Wcdb4Client.getWeflowDbPathCandidates(home), path.join(home, 'Documents', 'xwechat_files'), path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files') ] return Array.from(new Set(candidates)) } return [ path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files') ] } private static getWeflowDbPathCandidates(home: string): string[] { const configPaths = [ path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'), path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json') ] const candidates: string[] = [] for (const configPath of configPaths) { try { const config = fs.readJsonSync(configPath) as { dbPath?: unknown } if (typeof config.dbPath === 'string' && config.dbPath.trim()) { candidates.push(config.dbPath.trim()) } } catch { // WeFlow is optional; ignore missing or unreadable config. } } return candidates } private static hasDbStorage(candidate: string): boolean { try { return ( fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage')) ) } catch { return false } } private static isUsableRoot(candidate: string): boolean { if (!fs.existsSync(candidate)) return false if (Wcdb4Client.hasDbStorage(candidate)) return true try { return fs .readdirSync(candidate) .some((name) => Wcdb4Client.hasDbStorage(path.join(candidate, name))) } catch { return false } } private static hasSessionDb(candidate: string): boolean { return ( fs.existsSync(path.join(candidate, 'db_storage', 'session', 'session.db')) || fs.existsSync(path.join(candidate, 'db_storage', 'session.db')) ) } private static compareAccountDirs(a: string, b: string): number { const aHasSession = Wcdb4Client.hasSessionDb(a) const bHasSession = Wcdb4Client.hasSessionDb(b) if (aHasSession !== bHasSession) return aHasSession ? -1 : 1 try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs } catch { return 0 } } private static cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) if (match) return match[1] return trimmed } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) return suffixMatch ? suffixMatch[1] : trimmed } open(): void { this.loadNativeLibrary() if (!this.wcdbOpenAccount) { throw new Error('WCDB 4.0 native 接口未就绪') } let openResult = -1 const handleOut: WcdbHandleOut = [0] openResult = this.wcdbOpenAccount(this.sessionDbPath, this.key, handleOut) if (openResult !== 0 || handleOut[0] <= 0) { const hint = openResult === -1005 ? ';这通常表示密钥与当前账号数据库不匹配,请确认微信已登录目标账号,并重新自动获取密钥' : '' throw new Error( `wcdb_open_account 失败,错误码: ${openResult}${hint}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}` ) } this.handle = handleOut[0] if (this.wcdbSetMyWxid) { try { this.wcdbSetMyWxid(this.handle, this.wxid) } catch { // Optional helper. Failure does not block message reads. } } } async openAsync(): Promise { this.loadNativeLibrary() if (!this.wcdbOpenAccountAsync) { throw new Error('WCDB 4.0 native open async interface unavailable') } const handleOut: WcdbHandleOut = [0] const openResult = await this.callAsyncCode( this.wcdbOpenAccountAsync, this.sessionDbPath, this.key, handleOut ) if (openResult !== 0 || handleOut[0] <= 0) { throw new Error( `wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}` ) } this.handle = handleOut[0] if (this.wcdbSetMyWxid) { try { await this.callAsyncCode( this.wcdbSetMyWxid as unknown as KoffiAsyncFunction, this.handle, this.wxid ) } catch { // Optional helper. Failure does not block message reads. } } } close(): void { this.closing = true this.stopMonitor() const canShutdownNative = this.nativeCallsInFlight.size === 0 if (!canShutdownNative) { console.warn( `[WCDB4] skip synchronous native shutdown; ${this.nativeCallsInFlight.size} async call(s) still running` ) } this.finishClose(canShutdownNative) } async closeAsync(timeoutMs = 8_000): Promise { this.closing = true this.stopMonitorTransport() const drained = await this.waitForNativeCalls(timeoutMs) this.stopNativeMonitor() if (!drained) { console.warn( `[WCDB4] native calls did not drain within ${timeoutMs}ms; skip wcdb_shutdown during app quit` ) } this.finishClose(drained) return drained } private finishClose(shutdownNative: boolean): void { if ( shutdownNative && this.handle !== null && this.wcdbShutdown && process.platform !== 'win32' ) { try { this.wcdbShutdown() } catch { // Shutdown is best-effort after all tracked native calls have completed. } } this.handle = null this.cachedSessions = null this.cachedChatTables = null this.sessionsInFlight = null this.sessionDisplayNamesInFlight = null this.sessionDisplayNamesHydrated = false this.sessionStatusesInFlight = null this.sessionStatusesUpdatedAt = 0 this.displayNameCache.clear() this.avatarCache.clear() this.sessionStatusCache.clear() this.groupNicknameCache.clear() } private waitForNativeCalls(timeoutMs: number): Promise { const pending = Array.from(this.nativeCallsInFlight) if (pending.length === 0) return Promise.resolve(true) return new Promise((resolve) => { let settled = false const finish = (drained: boolean): void => { if (settled) return settled = true clearTimeout(timer) resolve(drained) } const timer = setTimeout(() => finish(false), timeoutMs) void Promise.allSettled(pending).then(() => { // Koffi promise resolution can resume higher-level async cleanup (for example, // closing a message cursor) in the same event-loop turn. Give those continuations // one full turn before shutting down the process-wide WCDB runtime. setImmediate(() => finish(this.nativeCallsInFlight.size === 0)) }) }) } async startMonitor(callback: (type: string, json: string) => void): Promise { if (this.closing || !this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) { return false } this.stopMonitor() this.monitorCallback = callback try { const startResult = await this.callAsyncCode( this.wcdbStartMonitorPipe as unknown as KoffiAsyncFunction ) if (startResult !== 0) { this.monitorCallback = null console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`) return false } this.monitorStarted = true if (this.closing) { this.stopMonitor() return false } const outName: WcdbVoidOut = [null] const nameResult = await this.callAsyncCode( this.wcdbGetMonitorPipeName as unknown as KoffiAsyncFunction, outName ) if (nameResult !== 0 || !outName[0]) { console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`) this.stopMonitor() return false } try { this.monitorPipePath = this.koffi.decode(outName[0], 'char', -1).trim() } finally { this.wcdbFreeString?.(outName[0]) } if (!this.monitorPipePath) { this.stopMonitor() return false } this.connectMonitorPipe() return true } catch (error) { console.warn('[WCDB4] 启动数据库监听失败:', error) this.stopMonitor() return false } } stopMonitor(): void { this.stopMonitorTransport() this.stopNativeMonitor() } private stopMonitorTransport(): void { this.monitorCallback = null if (this.monitorConnectTimer) { clearTimeout(this.monitorConnectTimer) this.monitorConnectTimer = null } if (this.monitorReconnectTimer) { clearTimeout(this.monitorReconnectTimer) this.monitorReconnectTimer = null } if (this.monitorPipeClient) { this.monitorPipeClient.destroy() this.monitorPipeClient = null } } private stopNativeMonitor(): void { if (this.monitorStarted && this.wcdbStopMonitorPipe) { try { this.wcdbStopMonitorPipe() } catch { // Native monitor cleanup is best-effort during reconnect or shutdown. } } this.monitorStarted = false this.monitorPipePath = '' } private connectMonitorPipe(): void { if (!this.monitorCallback || !this.monitorPipePath || this.monitorConnectTimer) return this.monitorConnectTimer = setTimeout(() => { this.monitorConnectTimer = null if (!this.monitorCallback || !this.monitorPipePath || this.monitorPipeClient) return const socket = createConnection(this.monitorPipePath) this.monitorPipeClient = socket let buffer = '' socket.on('data', (data) => { const normalizedChunk = data .toString('utf8') .split('\0') .join('\n') .replace(/}\s*{/g, '}\n{') buffer += normalizedChunk const lines = buffer.split(/\r?\n/) buffer = lines.pop() || '' for (const line of lines) this.emitMonitorPayload(line) const tail = buffer.trim() if (tail.startsWith('{') && tail.endsWith('}')) { try { JSON.parse(tail) this.emitMonitorPayload(tail) buffer = '' } catch { // Keep the partial payload until the next socket chunk arrives. } } }) socket.on('error', (error) => { console.warn('[WCDB4] 数据库监听管道异常:', error.message) }) socket.on('close', () => { if (this.monitorPipeClient === socket) this.monitorPipeClient = null this.scheduleMonitorReconnect() }) }, 100) } private emitMonitorPayload(rawPayload: string): void { const payload = rawPayload.trim() if (!payload || !this.monitorCallback) return try { const parsed = JSON.parse(payload) as { action?: string } this.monitorCallback(parsed.action || 'update', payload) } catch { this.monitorCallback('update', payload) } } private scheduleMonitorReconnect(): void { if (this.monitorReconnectTimer || !this.monitorCallback || !this.monitorPipePath) return this.monitorReconnectTimer = setTimeout(() => { this.monitorReconnectTimer = null this.connectMonitorPipe() }, 3000) } getSessions(): Wcdb4Session[] { if (this.cachedSessions) return this.cachedSessions if (!this.wcdbGetSessions) return [] const rows = this.readSessionRows() const sessions = (Array.isArray(rows) ? rows : []) .map((row) => this.normalizeSession(row)) .filter((session) => session.username) this.cachedSessions = sessions.map((session) => ({ ...session, nickname: this.displayNameCache.get(session.username) || session.nickname || session.username })) this.sessionDisplayNamesHydrated = false return this.cachedSessions } async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise { const hydrateDisplayNames = options.hydrateDisplayNames !== false if (this.cachedSessions) { if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions } if (this.sessionsInFlight) { await this.sessionsInFlight if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions || [] } if (!this.wcdbGetSessions) return [] const generation = this.sessionCacheGeneration const request = (async (): Promise => { const rows = await this.callJsonAsync[]>( this.wcdbGetSessions as unknown as KoffiAsyncFunction ) const sessions = (Array.isArray(rows) ? rows : []) .map((row) => this.normalizeSession(row)) .filter((session) => session.username) if (generation === this.sessionCacheGeneration) this.cachedSessions = sessions return sessions })() this.sessionsInFlight = request try { await request } finally { if (this.sessionsInFlight === request) this.sessionsInFlight = null } if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions || [] } private async refreshSessionStatusesAsync(): Promise { if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return if (this.sessionStatusesInFlight) { await this.sessionStatusesInFlight return } const sessions = this.cachedSessions if (!sessions?.length || !this.wcdbGetContactStatus) return const groupUsernames = sessions .map((session) => session.username) .filter((username) => username.endsWith('@chatroom')) if (!groupUsernames.length) { this.sessionStatusesUpdatedAt = Date.now() return } const request = (async (): Promise => { try { const map = await this.callJsonAsync< Record >( this.wcdbGetContactStatus as unknown as KoffiAsyncFunction, JSON.stringify(groupUsernames) ) for (const username of groupUsernames) { const status = map?.[username] this.sessionStatusCache.set(username, { isFolded: Boolean(status?.isFolded), isMuted: Boolean(status?.isMuted) }) } if (this.cachedSessions) { this.cachedSessions = this.cachedSessions.map((session) => { const status = this.sessionStatusCache.get(session.username) return status ? { ...session, ...status } : session }) } this.sessionStatusesUpdatedAt = Date.now() } catch (error) { console.warn('[WCDB4] session status lookup failed:', error) } })() this.sessionStatusesInFlight = request try { await request } finally { if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null } } invalidateSessionCache(): void { this.sessionCacheGeneration += 1 this.cachedSessions = null this.cachedChatTables = null this.sessionsInFlight = null this.sessionDisplayNamesHydrated = false } getChatTables(): { name: string; db_number: string }[] { if (this.cachedChatTables) return this.cachedChatTables const sessions = this.cachedSessions || this.readSessionRows() .map((row) => this.normalizeSession(row)) .filter((session) => session.username) this.cachedChatTables = sessions.map((session) => ({ name: `Chat_${this.md5(session.username)}`, db_number: session.username })) return this.cachedChatTables } getMessages( username: string, startTime?: number, endTime?: number, options: Wcdb4MessageQueryOptions = {} ): Wcdb4Message[] { const startedAt = Date.now() const maxRows = this.normalizeMessageLimit(options.limit) console.log( `[WCDB4] getMessages begin username=${username} start=${startTime || 0} end=${endTime || 0} limit=${maxRows || 0}` ) try { const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows) if (cursorMessages) { const recoveredMessages = this.readRecallJournal(username, startTime, endTime) const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows) console.log( `[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms` ) return mergedMessages } } catch (error) { console.warn(`[WCDB4] cursor messages failed username=${username}:`, error) } if (maxRows) { const tableRows = this.getMessagesByTableScan(username, startTime, endTime, maxRows) if (tableRows.length > 0) { console.log( `[WCDB4] getMessages table scan ok username=${username} rows=${tableRows.length} cost=${Date.now() - startedAt}ms` ) return tableRows } } if (!this.wcdbGetMessages) return [] const allRows: Record[] = [] const limit = 1000 let offset = 0 while (true) { let rows: Record[] try { rows = this.callJson[]>((handle, outJson) => this.wcdbGetMessages!(handle, username, limit, offset, outJson) ) } catch (error) { console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error) break } const batch = Array.isArray(rows) ? rows : [] allRows.push(...batch) if (batch.length < limit) break if (maxRows && allRows.length >= maxRows) break offset += limit } if (allRows.length === 0) { const tableRows = this.getMessagesByTableScan(username, startTime, endTime, maxRows) if (tableRows.length > 0) { console.log( `[WCDB4] getMessages table scan ok username=${username} rows=${tableRows.length} cost=${Date.now() - startedAt}ms` ) return tableRows } } const messages = this.finalizeMessages(username, allRows, startTime, endTime, maxRows) console.log( `[WCDB4] getMessages direct ok username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms` ) return messages } async getMessagesAsync( username: string, startTime?: number, endTime?: number, options: Wcdb4MessageQueryOptions = {} ): Promise { const startedAt = Date.now() const maxRows = this.normalizeMessageLimit(options.limit) let cursorMessages: Wcdb4Message[] = [] try { cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows) } catch (error) { console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error) } if (endTime && (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery)) { throw new Error('当前数据服务无法检查历史消息分片,请更新应用或核对微信数据版本') } // Older pages may live in message shards that the native cursor does not // enumerate. A bounded query must inspect all matching stores so history // cannot silently stop at a shard boundary. let tableMessages: Wcdb4Message[] = [] if (endTime || cursorMessages.length === 0) { tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows) } const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows) console.log( `[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms` ) return messages } private readSessionRows(): Record[] { if (!this.wcdbGetSessions) return [] const rows = this.callJson[]>((handle, outJson) => this.wcdbGetSessions!(handle, outJson) ) return Array.isArray(rows) ? rows : [] } private getMessagesByTableScan( username: string, startTime?: number, endTime?: number, limit?: number ): Wcdb4Message[] { if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] let tables: Wcdb4MessageStore[] = [] try { tables = this.listMessageStores(username) } catch (error) { console.warn(`[WCDB4] message table stats failed username=${username}:`, error) return [] } const allRows: Record[] = [] const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) const where = [ begin > 0 ? `"create_time" >= ${begin}` : '', end > 0 ? `"create_time" <= ${end}` : '' ].filter(Boolean) const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : '' for (const table of tables) { try { const order = limit ? 'DESC' : 'ASC' const rowLimit = limit || 5000 const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` const rows = this.callJson[]>((handle, outJson) => this.wcdbExecQuery!(handle, 'message', table.dbPath, sql, outJson) ) if (Array.isArray(rows)) allRows.push(...rows) } catch (error) { console.warn( `[WCDB4] message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`, error ) } } return this.finalizeMessages(username, allRows, startTime, endTime, limit) } private async getMessagesByTableScanAsync( username: string, startTime?: number, endTime?: number, limit?: number ): Promise { if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] let tables: Wcdb4MessageStore[] = [] try { const rows = await this.callJsonAsync[]>( this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction, username ) tables = (Array.isArray(rows) ? rows : []) .map((row) => ({ tableName: this.pickString(row, ['table_name', 'tableName', 'name']), dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) })) .filter((row) => row.tableName && row.dbPath) } catch (error) { console.warn(`[WCDB4] async message table stats failed username=${username}:`, error) throw new Error( `无法读取历史消息分片信息:${error instanceof Error ? error.message : String(error)}` ) } const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) const where = [ begin > 0 ? `"create_time" >= ${begin}` : '', end > 0 ? `"create_time" <= ${end}` : '' ].filter(Boolean) const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : '' const rowLimit = limit || 5000 const order = limit ? 'DESC' : 'ASC' const allRows: Record[] = [] let successfulTables = 0 for (const table of tables) { try { const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}` const rows = await this.callJsonAsync[]>( this.wcdbExecQuery as unknown as KoffiAsyncFunction, 'message', table.dbPath, sql ) successfulTables += 1 if (Array.isArray(rows)) allRows.push(...rows) } catch (error) { console.warn( `[WCDB4] async message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`, error ) } } if (tables.length > 0 && successfulTables === 0) { throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本') } return this.finalizeMessages(username, allRows, startTime, endTime, limit) } installRecallJournal(usernames: string[]): { installed: number; failed: number } { const stores = new Map() for (const username of this.uniq(usernames)) { try { for (const store of this.listMessageStores(username)) { stores.set(`${store.dbPath}\u0000${store.tableName}`, store) } } catch (error) { console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error) } } let installed = 0 let failed = 0 for (const store of stores.values()) { try { const columns = this.readMessageColumns(store) if (columns.length === 0) throw new Error('消息表没有可归档列') this.ensureRecallJournalTable(store, columns) this.createRecallJournalTrigger(store, columns) installed += 1 } catch (error) { failed += 1 console.warn( `[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`, error ) } } return { installed, failed } } private listMessageStores(username: string): Wcdb4MessageStore[] { if (!this.wcdbGetMessageTableStats) return [] const rows = this.callJson[]>((handle, outJson) => this.wcdbGetMessageTableStats!(handle, username, outJson) ) return (Array.isArray(rows) ? rows : []) .map((row) => ({ tableName: this.pickString(row, ['table_name', 'tableName', 'name']), dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) })) .filter((row) => row.tableName && row.dbPath) } private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record[] { if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') const rows = this.callJson[]>((handle, outJson) => this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson) ) return Array.isArray(rows) ? rows : [] } private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] { const rows = this.executeMessageSql( store, `PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})` ) return rows .map((row) => { const name = this.pickString(row, ['name']) const type = this.pickString(row, ['type']).toUpperCase() const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB' return { name, declaration } }) .filter((column) => column.name) } private ensureRecallJournalTable( store: Wcdb4MessageStore, columns: { name: string; declaration: string }[] ): void { const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) const definitions = columns .map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`) .join(', ') this.executeMessageSql( store, `CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)` ) } private createRecallJournalTrigger( store: Wcdb4MessageStore, columns: { name: string; declaration: string }[] ): void { const table = this.quoteSqlIdentifier(store.tableName) const triggerName = this.quoteSqlIdentifier( `_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}` ) const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) const targetColumns = [ ...columns.map((column) => this.quoteSqlIdentifier(column.name)), '"_wxe_source_table"', '"_wxe_captured_at"' ].join(', ') const sourceValues = [ ...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`), `'${store.tableName.replace(/'/g, "''")}'`, `CAST(strftime('%s', 'now') AS INTEGER)` ].join(', ') this.executeMessageSql( store, `CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END` ) } private readRecallJournal( username: string, startTime?: number, endTime?: number ): Wcdb4Message[] { const recoveredRows: Record[] = [] for (const store of this.listMessageStores(username)) { try { const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) const where = [ `"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`, begin > 0 ? `"create_time" >= ${begin}` : '', end > 0 ? `"create_time" <= ${end}` : '' ].filter(Boolean) const rows = this.executeMessageSql( store, `SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500` ) recoveredRows.push(...rows) } catch { // The journal is optional until installation has completed for this store. } } return this.finalizeMessages(username, recoveredRows, startTime, endTime) } private mergeMessageRows( current: Wcdb4Message[], recovered: Wcdb4Message[], limit?: number ): Wcdb4Message[] { const merged = new Map() for (const message of [...recovered, ...current]) { const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) const serverId = String(message.serverId || '').trim() const identity = serverId && serverId !== '0' ? `server:${serverId}` : message.mesLocalID ? `local:${message.mesLocalID}:${message.msgCreateTime || 0}` : `${message.msgCreateTime}:${message.msgContent}` const key = recoveredRow ? `recovered:${identity}` : identity merged.set(key, message) } const messages = Array.from(merged.values()).sort( (left, right) => Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) || Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0) ) return limit && messages.length > limit ? messages.slice(-limit) : messages } private recallJournalTableName(messageTableName: string): string { return `_wxe_recall_journal_${crypto .createHash('sha1') .update(messageTableName) .digest('hex') .slice(0, 16)}` } getMyAvatarUrl(): string | undefined { const candidates = this.getMyUsernameCandidates() this.hydrateAvatarUrls(candidates) for (const candidate of candidates) { const avatar = this.avatarCache.get(candidate) if (avatar) return avatar } return undefined } getAvatarUrls(usernames: string[]): Record { const normalized = this.uniq(usernames) this.hydrateAvatarUrls(normalized) const result: Record = {} for (const username of normalized) { const avatar = this.avatarCache.get(username) if (avatar) result[username] = avatar } return result } async getAvatarUrlsAsync(usernames: string[]): Promise> { const normalized = this.uniq(usernames) await this.hydrateAvatarUrlsAsync(normalized) const localCandidates = normalized.filter((username) => { const avatar = this.avatarCache.get(username) return !avatar || !avatar.startsWith('data:') }) if (localCandidates.length && this.wcdbGetHeadImageBuffers) { try { const buffers = await this.callJsonAsync>( this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction, JSON.stringify(localCandidates) ) for (const [username, hex] of Object.entries(buffers || {})) { const avatar = this.avatarHexToDataUrl(hex) if (avatar) this.avatarCache.set(username, avatar) } } catch (error) { console.warn('[WCDB4] local avatar fallback failed:', error) } } const result: Record = {} for (const username of normalized) { const avatar = this.avatarCache.get(username) if (avatar) result[username] = avatar } return result } private avatarHexToDataUrl(value: string): string | undefined { const hex = String(value || '').trim() if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined const buffer = Buffer.from(hex, 'hex') let mime = 'image/jpeg' if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png' if ( buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP' ) { mime = 'image/webp' } return `data:${mime};base64,${buffer.toString('base64')}` } getMyGroupNickname(chatroomId: string): string | undefined { const groupNicknames = this.getGroupNicknames(chatroomId) for (const candidate of this.getMyUsernameCandidates()) { const nickname = groupNicknames.get(candidate) if (nickname) return nickname } return undefined } private getMessagesByCursor( username: string, startTime?: number, endTime?: number, limit?: number ): Wcdb4Message[] | null { if ( !this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch || !this.wcdbCloseMessageCursor ) { return null } const handle = this.ensureHandle() const batchSize = limit ? Math.min(500, limit) : 1000 const cursorOut: WcdbHandleOut = [0] const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) const ascending = limit ? 0 : 1 const openResult = this.wcdbOpenMessageCursor( handle, username, batchSize, ascending, begin, end, cursorOut ) if (openResult !== 0 || cursorOut[0] <= 0) { return null } const cursor = cursorOut[0] const allRows: Record[] = [] try { while (true) { const outJson: WcdbVoidOut = [null] const outHasMore: [number] = [0] const fetchResult = this.wcdbFetchMessageBatch!(handle, cursor, outJson, outHasMore) if (fetchResult !== 0 || !outJson[0]) break try { const json = this.koffi!.decode(outJson[0], 'char', -1) const batch = JSON.parse(json) as Record[] if (Array.isArray(batch)) allRows.push(...batch) } finally { this.wcdbFreeString?.(outJson[0]) } if (!outHasMore[0]) break if (limit && allRows.length >= limit) break } } finally { try { this.wcdbCloseMessageCursor?.(handle, cursor) } catch { // Best effort cleanup; a stale cursor is less harmful than blocking UI. } } return this.finalizeMessages(username, allRows, startTime, endTime, limit) } private finalizeMessages( username: string, rows: Record[], startTime?: number, endTime?: number, limit?: number ): Wcdb4Message[] { const messages = rows.map((row) => this.normalizeMessage(row)) const sorted = messages .filter((message) => { const createTime = Number(message.msgCreateTime) if (startTime && createTime < startTime) return false if (endTime && createTime > endTime) return false return true }) .sort((a, b) => Number(a.msgCreateTime) - Number(b.msgCreateTime)) const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted return visibleMessages.map((message) => { if (!message.sender) return message const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar const shouldPrefixSender = username.endsWith('@chatroom') && message.mesDes === 1 && message.sender && message.msgContent && !message.msgContent.startsWith(`${message.sender}:`) return { ...message, senderNickname, senderAvatar, msgContent: shouldPrefixSender ? `${message.sender}:\n${message.msgContent}` : message.msgContent } }) } private normalizeMessageLimit(limit?: number): number | undefined { const normalized = Number(limit) if (!Number.isFinite(normalized) || normalized <= 0) return undefined return Math.max(1, Math.min(5000, Math.floor(normalized))) } getGroupMembers(chatroomId: string): Wcdb4GroupMember[] { if (!this.wcdbGetGroupMembers || !chatroomId) return [] try { const groupNicknames = this.getGroupNicknames(chatroomId) const rows = this.callJson[]>((handle, outJson) => this.wcdbGetGroupMembers!(handle, chatroomId, outJson) ) const members = (Array.isArray(rows) ? rows : []).map((row) => { const username = this.pickString(row, [ 'username', 'userName', 'user_name', 'member_username', 'm_nsUsrName' ]) const wechatNickname = this.pickString(row, [ 'nickname', 'nickName', 'wechatNickname', 'wechat_nickname', 'm_nsNickName' ]) const remark = this.pickString(row, [ 'remark', 'remarkName', 'remark_name', 'contactRemark', 'contact_remark' ]) const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name']) const avatar = this.pickString(row, [ 'avatarUrl', 'avatar_url', 'headImgUrl', 'm_nsHeadImgUrl' ]) if (username) { if (avatar) this.avatarCache.set(username, avatar) } return { m_nsUsrName: username, nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname, groupNickname: groupNicknames.get(username) || '', wechatNickname: wechatNickname || memberNickname, remark, m_nsHeadImgUrl: avatar } }) const missingDisplayNames = members .filter((member) => !member.nickname) .map((member) => member.m_nsUsrName) .filter(Boolean) this.hydrateDisplayNames(missingDisplayNames) this.hydrateAvatarUrls( members .filter((member) => !member.m_nsHeadImgUrl) .map((member) => member.m_nsUsrName) .filter(Boolean) ) return members.map((member) => ({ ...member, nickname: member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, wechatNickname: member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '', m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' })) } catch { return [] } } private async getMessagesByCursorAsync( username: string, startTime?: number, endTime?: number, limit?: number ): Promise { if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return [] const handle = this.ensureHandle() const batchSize = limit ? Math.min(500, limit) : 1000 const cursorOut: WcdbHandleOut = [0] const begin = this.normalizeTimestamp(startTime || 0) const end = this.normalizeTimestamp(endTime || 0) const ascending = limit ? 0 : 1 const openResult = await this.callAsyncCode( this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction, handle, username, batchSize, ascending, begin, end, cursorOut ) if (openResult !== 0 || cursorOut[0] <= 0) return [] const cursor = cursorOut[0] const allRows: Record[] = [] try { while (true) { const outJson: WcdbVoidOut = [null] const outHasMore: [number] = [0] const fetchResult = await this.callAsyncCode( this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction, handle, cursor, outJson, outHasMore ) if (fetchResult !== 0 || !outJson[0]) break try { const json = this.koffi!.decode(outJson[0], 'char', -1) const batch = JSON.parse(json) as Record[] if (Array.isArray(batch)) allRows.push(...batch) } finally { this.wcdbFreeString?.(outJson[0]) } if (!outHasMore[0] || (limit && allRows.length >= limit)) break } } finally { try { this.wcdbCloseMessageCursor?.(handle, cursor) } catch { // Best-effort cursor cleanup. } } return this.finalizeMessages(username, allRows, startTime, endTime, limit) } async getGroupMembersAsync(chatroomId: string): Promise { if (!this.wcdbGetGroupMembers || !chatroomId) return [] try { const groupNicknames = await this.getGroupNicknamesAsync(chatroomId) const rows = await this.callJsonAsync[]>( this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction, chatroomId ) const members = (Array.isArray(rows) ? rows : []).map((row) => { const username = this.pickString(row, [ 'username', 'userName', 'user_name', 'member_username', 'm_nsUsrName' ]) const wechatNickname = this.pickString(row, [ 'nickname', 'nickName', 'wechatNickname', 'wechat_nickname', 'm_nsNickName' ]) const remark = this.pickString(row, [ 'remark', 'remarkName', 'remark_name', 'contactRemark', 'contact_remark' ]) const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name']) const avatar = this.pickString(row, [ 'avatarUrl', 'avatar_url', 'headImgUrl', 'm_nsHeadImgUrl' ]) if (username && avatar) this.avatarCache.set(username, avatar) return { m_nsUsrName: username, nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname, groupNickname: groupNicknames.get(username) || '', wechatNickname: wechatNickname || memberNickname, remark, m_nsHeadImgUrl: avatar } }) const missingNames = members .filter((member) => !member.nickname) .map((member) => member.m_nsUsrName) .filter(Boolean) const missingAvatars = members .filter((member) => !member.m_nsHeadImgUrl) .map((member) => member.m_nsUsrName) .filter(Boolean) await Promise.all([ this.hydrateDisplayNamesAsync(missingNames), this.hydrateAvatarUrlsAsync(missingAvatars) ]) return members.map((member) => ({ ...member, nickname: member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName, wechatNickname: member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '', m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || '' })) } catch (error) { console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error) return [] } } getGroupNicknames(chatroomId: string): Map { const cached = this.groupNicknameCache.get(chatroomId) if (cached) return cached const nicknames = new Map() if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames try { const rows = this.callJson | Record[]>( (handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson) ) this.readStringMap(rows, [ 'nickname', 'nickName', 'displayName', 'display_name', 'groupNickname', 'group_nickname', 'name' ]).forEach((nickname, username) => nicknames.set(username, nickname)) this.groupNicknameCache.set(chatroomId, nicknames) } catch (error) { console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error) } return nicknames } private async getGroupNicknamesAsync(chatroomId: string): Promise> { const cached = this.groupNicknameCache.get(chatroomId) if (cached) return cached const nicknames = new Map() if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames const rows = await this.callJsonAsync | Record[]>( this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId ) this.readStringMap(rows, [ 'nickname', 'nickName', 'displayName', 'display_name', 'groupNickname', 'group_nickname', 'name' ]).forEach((nickname, username) => nicknames.set(username, nickname)) this.groupNicknameCache.set(chatroomId, nicknames) return nicknames } async getVoiceData( sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0 ): Promise<{ success: boolean; hex?: string; error: string }> { if (!this.wcdbGetVoiceData) { return { success: false, error: '当前 DLL 版本不支持获取语音数据' } } const handle = this.ensureHandle() const outHex: WcdbVoidOut = [null] try { const result = this.wcdbGetVoiceData( handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outHex ) if (result !== 0 || !outHex[0]) { return { success: false, error: `获取语音数据失败: ${result}` } } const hex = this.decodeHexPtr(outHex[0]) if (hex === null) { return { success: false, error: '解析语音数据失败' } } return { success: true, hex, error: '' } } finally { this.wcdbFreeString?.(outHex[0]) } } private decodeHexPtr(ptr: unknown): string | null { if (!ptr || !this.koffi) return null try { const hex = this.koffi.decode(ptr, 'char', -1) return typeof hex === 'string' ? hex : null } catch { return null } } getUsernameByMd5(md5: string): string | undefined { return this.getSessions().find((session) => this.md5(session.username) === md5)?.username } getAccountRoot(): string { return this.accountRoot } getKey(): string { return this.key } resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null { if (!this.wcdbResolveImageHardlink) return null const normalizedMd5 = String(md5 || '') .trim() .toLowerCase() if (!normalizedMd5) return null try { return this.callJson((handle, outJson) => this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson) ) } catch (error) { console.warn('[WCDB4] resolve image hardlink failed:', error) return null } } async resolveImageHardlinkAsync(md5: string): Promise { if (!this.wcdbResolveImageHardlink) return null const normalizedMd5 = String(md5 || '') .trim() .toLowerCase() if (!normalizedMd5) return null try { return await this.callJsonAsync( this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction, normalizedMd5, this.accountRoot ) } catch (error) { console.warn('[WCDB4] async image hardlink resolve failed:', error) return null } } resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null { if (!this.wcdbResolveVideoHardlink) return null const normalizedMd5 = String(md5 || '') .trim() .toLowerCase() if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null try { return this.callJson((handle, outJson) => this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson) ) } catch (error) { console.warn('[WCDB4] resolve video hardlink failed:', error) return null } } resolveEmoticonCdnUrl(md5: string): string | undefined { if (!this.wcdbGetEmoticonCdnUrl) { console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`) return undefined } const normalizedMd5 = String(md5 || '') .trim() .toLowerCase() if (!/^[a-f0-9]{32}$/.test(normalizedMd5)) return undefined const dbPath = this.findEmoticonDb() if (!dbPath) { console.warn(`[WCDB4] emoticon.db not found for md5=${normalizedMd5}`) return undefined } const outUrl: WcdbVoidOut = [null] try { const result = this.wcdbGetEmoticonCdnUrl(this.ensureHandle(), dbPath, normalizedMd5, outUrl) if (result !== 0 || !outUrl[0] || !this.koffi) { console.warn( `[WCDB4] emoticon CDN URL lookup miss: result=${result}; md5=${normalizedMd5}; db=${dbPath}` ) return undefined } const url = this.koffi.decode(outUrl[0], 'char', -1).trim() return url || undefined } catch (error) { console.warn('[WCDB4] resolve emoticon CDN URL failed:', error) return undefined } finally { this.wcdbFreeString?.(outUrl[0]) } } md5(value: string): string { return crypto.createHash('md5').update(value).digest('hex') } private loadNativeLibrary(): void { if (this.koffi) return const koffi = nodeRequire('koffi') as KoffiModule this.koffi = koffi const libPath = this.findNativeLibrary() const libDir = path.dirname(libPath) console.log( `[WCDB4] loadNativeLibrary platform=${process.platform} arch=${process.arch} libPath=${libPath} cwd=${process.cwd()} resourcesPath=${process.resourcesPath || ''} WCDB_RESOURCES_PATH=${process.env.WCDB_RESOURCES_PATH || ''}` ) // Reuse the module-level bootstrap if main.ts already performed // InitProtection + wcdb_init; WCDB returns -1006 if wcdb_init is called // twice in the same process. let lib: KoffiLibrary if (wcdbBootstrapLib) { console.log('[WCDB4] reusing bootstrap lib, skip koffi.load and wcdb_init') lib = wcdbBootstrapLib } else { const preloadLibraries = process.platform === 'win32' ? ['WCDB.dll', 'SDL2.dll'] : process.platform === 'darwin' ? ['libWCDB.dylib'] : [] for (const name of preloadLibraries) { const preloadPath = path.join(libDir, name) if (!fs.existsSync(preloadPath)) continue try { koffi.load(preloadPath) console.log(`[WCDB4] preload ok ${preloadPath}`) } catch { console.warn(`[WCDB4] preload failed ${preloadPath}`) // Some builds resolve dependencies through the platform loader path. } } console.log(`[WCDB4] koffi.load begin ${libPath}`) lib = koffi.load(libPath) console.log(`[WCDB4] koffi.load ok ${libPath}`) this.initProtection(lib, libDir) const wcdbInit = lib.func('int32 wcdb_init()') as () => number console.log('[WCDB4] wcdb_init begin') const initResult = wcdbInit() console.log(`[WCDB4] wcdb_init rc=${initResult}`) if (initResult !== 0) { console.warn(`[WCDB4] wcdb_init 返回 ${initResult},继续尝试 wcdb_open_account`) } } this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number const openAccount = lib.func( 'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)' ) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number this.wcdbOpenAccount = openAccount this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void this.wcdbGetSessions = lib.func( 'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)' ) as (handle: number, outJson: WcdbVoidOut) => number this.wcdbGetMessages = lib.func( 'int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)' ) as ( handle: number, username: string, limit: number, offset: number, outJson: WcdbVoidOut ) => number try { this.wcdbGetMessageTableStats = lib.func( 'int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)' ) as (handle: number, username: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetMessageTableStats = null } this.wcdbGetDisplayNames = lib.func( 'int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)' ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number try { this.wcdbSetMyWxid = lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)') as ( handle: number, wxid: string ) => number } catch { this.wcdbSetMyWxid = null } try { this.wcdbGetAvatarUrls = lib.func( 'int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)' ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetAvatarUrls = null } try { this.wcdbGetContactStatus = lib.func( 'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)' ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetContactStatus = null } try { this.wcdbGetHeadImageBuffers = lib.func( 'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)' ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetHeadImageBuffers = null } try { this.wcdbExecQuery = lib.func( 'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)' ) as ( handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut ) => number } catch { this.wcdbExecQuery = null } try { this.wcdbGetGroupMembers = lib.func( 'int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)' ) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetGroupMembers = null } try { this.wcdbGetGroupNicknames = lib.func( 'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)' ) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number } catch { this.wcdbGetGroupNicknames = null } try { this.wcdbOpenMessageCursor = lib.func( 'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)' ) as ( handle: number, username: string, batchSize: number, ascending: number, beginTimestamp: number, endTimestamp: number, cursorOut: WcdbHandleOut ) => number this.wcdbFetchMessageBatch = lib.func( 'int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)' ) as (handle: number, cursor: number, outJson: WcdbVoidOut, outHasMore: [number]) => number this.wcdbCloseMessageCursor = lib.func( 'int32 wcdb_close_message_cursor(int64 handle, int64 cursor)' ) as (handle: number, cursor: number) => number } catch { this.wcdbOpenMessageCursor = null this.wcdbFetchMessageBatch = null this.wcdbCloseMessageCursor = null } try { this.wcdbGetVoiceData = lib.func( 'int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)' ) as ( handle: number, sessionId: string, createTime: number, localId: number, svrId: bigint, candidatesJson: string, outHex: WcdbVoidOut ) => number } catch { this.wcdbGetVoiceData = null } try { this.wcdbResolveImageHardlink = lib.func( 'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)' ) as (handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number } catch { this.wcdbResolveImageHardlink = null } try { this.wcdbResolveVideoHardlink = lib.func( 'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)' ) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number } catch { this.wcdbResolveVideoHardlink = null } try { this.wcdbGetEmoticonCdnUrl = lib.func( 'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)' ) as (handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number } catch { console.warn('[WCDB4] wcdb_get_emoticon_cdn_url symbol unavailable') this.wcdbGetEmoticonCdnUrl = null } try { this.wcdbStartMonitorPipe = lib.func('int32 wcdb_start_monitor_pipe()') as () => number this.wcdbStopMonitorPipe = lib.func('void wcdb_stop_monitor_pipe()') as () => void this.wcdbGetMonitorPipeName = lib.func( 'int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)' ) as (outName: WcdbVoidOut) => number } catch { console.warn('[WCDB4] monitor pipe symbols unavailable') this.wcdbStartMonitorPipe = null this.wcdbStopMonitorPipe = null this.wcdbGetMonitorPipeName = null } } private initProtection(lib: KoffiLibrary, libDir: string): void { const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as ( resourcePath: string ) => number const resourceRoots = Array.from( new Set( [ libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots() ].filter(Boolean) ) ) let lastCode = -1 for (const resourceRoot of resourceRoots) { try { console.log(`[WCDB4] InitProtection call ${resourceRoot}`) lastCode = initProtection(resourceRoot) console.log(`[WCDB4] InitProtection rc=${lastCode} path=${resourceRoot}`) if (lastCode === 0) { console.log(`[WCDB4] InitProtection ok path=${resourceRoot}`) return } } catch (error) { console.warn(`[WCDB4] InitProtection exception path=${resourceRoot}`, error) // Try next candidate. } } console.warn( `[WCDB4] InitProtection 返回 ${lastCode},继续尝试 wcdb_init/open; tried=${resourceRoots.join(' | ')}` ) } private findNativeLibrary(): string { return Wcdb4Client.resolveNativeLibrary() } static resolveNativeLibrary(): string { const libName = process.platform === 'darwin' ? 'libwcdb_api.dylib' : process.platform === 'linux' ? 'libwcdb_api.so' : 'wcdb_api.dll' const platformDir = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win32' : process.platform const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const resourceRoots = getResourceRoots() const candidates = [ process.env.WCDB_DLL_PATH, ...resourceRoots.flatMap((root) => [ path.join(root, 'wcdb', platformDir, archDir, libName), path.join(root, 'wcdb', platformDir, 'x64', libName), path.join(root, platformDir, libName), path.join(root, libName) ]) ].filter(Boolean) as string[] const found = candidates.find((candidate) => fs.existsSync(candidate)) if (!found) { throw new Error(`找不到 WCDB native 库: ${candidates.join(', ')}`) } if (process.platform === 'win32') { const runtimeDir = resourceRoots .map((root) => path.join(root, 'runtime', 'win32')) .find((candidate) => fs.existsSync(candidate)) const dllDir = path.dirname(found) process.env.PATH = [dllDir, runtimeDir || '', process.env.PATH || ''] .filter(Boolean) .join(path.delimiter) } return found } private findSessionDb(): string { const direct = path.join(this.dbStoragePath, 'session', 'session.db') if (fs.existsSync(direct)) return direct return this.findFile(this.dbStoragePath, 'session.db') || '' } private findEmoticonDb(): string { const candidates = [ path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'), path.join(this.dbStoragePath, 'emotion', 'emoticon.db'), path.join(this.accountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'), path.join(this.accountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db') ] for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate } return this.findFile(this.dbStoragePath, 'emoticon.db') || '' } private findFile(dir: string, filename: string, depth = 0): string | null { if (!fs.existsSync(dir) || depth > 5) return null const entries = fs.readdirSync(dir) for (const entry of entries) { const fullPath = path.join(dir, entry) if (entry.toLowerCase() === filename.toLowerCase() && fs.statSync(fullPath).isFile()) { return fullPath } } for (const entry of entries) { const fullPath = path.join(dir, entry) if (fs.statSync(fullPath).isDirectory()) { const found = this.findFile(fullPath, filename, depth + 1) if (found) return found } } return null } private callJson(call: (handle: number, outJson: WcdbVoidOut) => number): T { const handle = this.ensureHandle() const outJson: WcdbVoidOut = [null] const result = call(handle, outJson) if (result !== 0 || !outJson[0]) { throw new Error(`WCDB 调用失败,错误码: ${result}`) } try { const json = this.koffi!.decode(outJson[0], 'char', -1) return JSON.parse(json) as T } finally { this.wcdbFreeString?.(outJson[0]) } } private callJsonAsync(fn: KoffiAsyncFunction, ...args: unknown[]): Promise { const handle = this.ensureHandle() const outJson: WcdbVoidOut = [null] return this.createTrackedNativeCall((resolve, reject) => { fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => { if (error) { reject(error) return } const code = Number(result) if (code !== 0 || !outJson[0]) { reject(new Error(`WCDB async call failed, code: ${code}`)) return } try { const json = this.koffi!.decode(outJson[0], 'char', -1) resolve(JSON.parse(json) as T) } catch (decodeError) { reject(decodeError) } finally { this.wcdbFreeString?.(outJson[0]) } }) }) } private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise { return this.createTrackedNativeCall((resolve, reject) => { fn.async(...args, (error: unknown, result: unknown) => { if (error) reject(error) else resolve(Number(result)) }) }) } private createTrackedNativeCall( executor: (resolve: (value: T) => void, reject: (reason?: unknown) => void) => void ): Promise { if (this.closing) return Promise.reject(new Error('微信数据库正在关闭')) const promise = new Promise(executor) this.nativeCallsInFlight.add(promise) void promise.then( () => this.nativeCallsInFlight.delete(promise), () => this.nativeCallsInFlight.delete(promise) ) return promise } private ensureHandle(): number { if (this.closing) throw new Error('微信数据库正在关闭') if (!this.handle) throw new Error('微信 4.0 数据库未打开') return this.handle } private normalizeSession(row: Record): Wcdb4Session { const username = this.pickString(row, [ 'username', 'user_name', 'userName', 'usrName', 'UsrName', 'talker', 'talker_id', 'talkerId', 'sessionId', 'session_id' ]) const nickname = this.pickString(row, [ 'nickname', 'nickName', 'displayName', 'display_name', 'remark', 'name' ]) const wechatNickname = this.pickString(row, [ 'wechatNickname', 'wechat_nickname', 'nickname', 'nickName', 'name' ]) const remark = this.pickString(row, [ 'remark', 'remarkName', 'remark_name', 'contactRemark', 'contact_remark' ]) const status = this.sessionStatusCache.get(username) return { username, nickname, wechatNickname, remark, isFolded: status?.isFolded, isMuted: status?.isMuted, raw: row } } private normalizeMessage(row: Record): Wcdb4Message { const contentRaw = this.pickValue(row, [ 'message_content', 'messageContent', 'content', 'msg_content', 'msgContent', 'WCDB_CT_message_content' ]) const compressRaw = this.pickValue(row, [ 'compress_content', 'compressContent', 'compressed_content', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content', 'WCDB_CT_compressContent' ]) const content = this.decodeMessageContent(contentRaw, compressRaw) const sender = this.pickString(row, [ 'sender_username', 'senderUsername', 'sender', 'fromUsername', 'from_username', 'WCDB_CT_sender_username' ]) const createTime = this.pickNumber(row, [ 'create_time', 'createTime', 'msg_create_time', 'msgCreateTime', 'time', 'WCDB_CT_create_time' ]) const localId = this.pickString(row, [ 'local_id', 'localId', 'msg_local_id', 'msgLocalId', 'mesLocalID', 'id' ]) const serverId = this.pickString(row, [ 'server_id', 'serverId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'message_id', 'messageId', 'new_msg_id', 'newMsgId', 'WCDB_CT_server_id' ]) const messageType = this.pickString(row, [ 'local_type', 'localType', 'message_type', 'messageType', 'msg_type', 'msgType', 'type', 'WCDB_CT_local_type' ]) const isSend = this.pickBoolean(row, [ 'computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'mesDes', 'WCDB_CT_is_send' ]) return { mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`, serverId: serverId || undefined, mesDes: isSend ? 0 : 1, messageType: messageType || '1', msgCreateTime: String(createTime), msgContent: content, sender, senderNickname: sender ? this.displayNameCache.get(sender) : undefined, senderAvatar: sender ? this.avatarCache.get(sender) : undefined, raw: row } } private shouldHydrateSessionDisplayName(session: Wcdb4Session): boolean { const username = String(session.username || '').trim() const nickname = String(session.nickname || '').trim() if (!username) return false if (!nickname) return true if (nickname === username) return true if (nickname.endsWith('@chatroom')) return true if (nickname.startsWith('Group_') || nickname.startsWith('Unknown_')) return true return false } private hydrateDisplayNames(usernames: string[]): void { if (!this.wcdbGetDisplayNames) return const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) if (missing.length === 0) return try { const rows = this.callJson | Record[]>( (handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson) ) this.readStringMap(rows, [ 'nickname', 'displayName', 'display_name', 'remark', 'name' ]).forEach((name, username) => this.displayNameCache.set(username, name)) } catch { // Names are optional; usernames are still enough to load chats. } } private async hydrateDisplayNamesAsync(usernames: string[]): Promise { if (!this.wcdbGetDisplayNames) return const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) if (missing.length === 0) return try { const rows = await this.callJsonAsync | Record[]>( this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing) ) this.readStringMap(rows, [ 'nickname', 'displayName', 'display_name', 'remark', 'name' ]).forEach((name, username) => this.displayNameCache.set(username, name)) } catch { // Names are optional; usernames remain usable. } } private async ensureSessionDisplayNamesAsync(): Promise { if (this.sessionDisplayNamesHydrated || !this.cachedSessions) return if (this.sessionDisplayNamesInFlight) return this.sessionDisplayNamesInFlight const generation = this.sessionCacheGeneration const sessions = this.cachedSessions const request = (async (): Promise => { await this.hydrateDisplayNamesAsync( sessions .filter((session) => this.shouldHydrateSessionDisplayName(session)) .map((session) => session.username) ) if (generation !== this.sessionCacheGeneration || this.cachedSessions !== sessions) return this.cachedSessions = sessions.map((session) => ({ ...session, nickname: this.displayNameCache.get(session.username) || session.nickname || session.username })) this.sessionDisplayNamesHydrated = true })() this.sessionDisplayNamesInFlight = request try { await request } finally { if (this.sessionDisplayNamesInFlight === request) this.sessionDisplayNamesInFlight = null } } private hydrateAvatarUrls(usernames: string[]): void { const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) if (missing.length === 0) return if (this.wcdbGetAvatarUrls) { try { const rows = this.callJson | Record[]>( (handle, outJson) => this.wcdbGetAvatarUrls!(handle, JSON.stringify(missing), outJson) ) this.readStringMap(rows, [ 'avatarUrl', 'avatar_url', 'headImgUrl', 'm_nsHeadImgUrl', 'big_head_img_url', 'small_head_img_url' ]).forEach((avatar, username) => this.avatarCache.set(username, avatar)) } catch { // Try the contact database below. } } const stillMissing = missing.filter((username) => !this.avatarCache.has(username)) if (stillMissing.length === 0) return try { this.readContactAvatarUrls(stillMissing).forEach((avatar, username) => this.avatarCache.set(username, avatar) ) } catch { // Avatars are optional. } } private async hydrateAvatarUrlsAsync(usernames: string[]): Promise { if (!this.wcdbGetAvatarUrls) return const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) if (missing.length === 0) return try { const rows = await this.callJsonAsync | Record[]>( this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing) ) this.readStringMap(rows, [ 'avatarUrl', 'avatar_url', 'headImgUrl', 'm_nsHeadImgUrl', 'big_head_img_url', 'small_head_img_url' ]).forEach((avatar, username) => this.avatarCache.set(username, avatar)) } catch { // Avatars are optional. } } private readContactAvatarUrls(usernames: string[]): Map { const result = new Map() if (!this.wcdbExecQuery || usernames.length === 0) return result const inList = this.uniq(usernames) .map((username) => `'${username.replace(/'/g, "''")}'`) .join(',') if (!inList) return result const sql = `SELECT * FROM contact WHERE username IN (${inList})` const rows = this.callJson[]>((handle, outJson) => this.wcdbExecQuery!(handle, 'contact', '', sql, outJson) ) if (!Array.isArray(rows)) return result for (const row of rows) { const username = this.pickString(row, ['username', 'user_name', 'userName']) const avatar = this.pickString(row, [ 'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url', 'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url', 'head_img_url', 'headImgUrl', 'avatar_url', 'avatarUrl' ]) if (username && avatar) result.set(username, avatar) } return result } private readStringMap( rows: Record | Record[], valueKeys: string[] ): Map { const result = new Map() if (Array.isArray(rows)) { for (const row of rows) { const username = this.pickString(row, ['username', 'userName', 'user_name', 'm_nsUsrName']) const value = this.pickString(row, valueKeys) if (username && value) result.set(username, value) } return result } for (const [username, value] of Object.entries(rows || {})) { if (username && value) result.set(username, String(value)) } return result } private pickString(row: Record, keys: string[]): string { for (const key of keys) { const value = this.pickValue(row, [key]) if (typeof value === 'string' && value.trim()) return value.trim() if (typeof value === 'number' && Number.isFinite(value)) return String(value) } return '' } private pickValue(row: Record, keys: string[]): unknown { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(row, key)) return row[key] const foundKey = Object.keys(row).find( (candidate) => candidate.toLowerCase() === key.toLowerCase() ) if (foundKey) return row[foundKey] } return undefined } private decodeMessageContent(messageContent: unknown, compressContent: unknown): string { const compressed = this.decodeMaybeCompressed(compressContent) if (compressed) return compressed return this.decodeMaybeCompressed(messageContent) } private decodeMaybeCompressed(raw: unknown): string { if (raw === null || raw === undefined) return '' if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw) if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw)) if (Array.isArray(raw)) return this.decodeBinaryContent(Buffer.from(raw)) if (typeof raw === 'number') return Number.isFinite(raw) ? String(raw) : '' if (typeof raw !== 'string') { const data = (raw as { data?: unknown })?.data if (Array.isArray(data)) return this.decodeBinaryContent(Buffer.from(data)) return '' } const trimmed = raw.trim() if (!trimmed) return '' if (/^[0-9]+$/.test(trimmed)) return trimmed if (trimmed.length > 16 && this.looksLikeHex(trimmed)) { try { const decoded = this.decodeBinaryContent(Buffer.from(trimmed, 'hex')) if (decoded) return decoded } catch { // Fall back to the original string below. } } if (trimmed.length > 16 && this.looksLikeBase64(trimmed)) { try { const decoded = this.decodeBinaryContent(Buffer.from(trimmed, 'base64')) if (decoded) return decoded } catch { // Fall back to the original string below. } } return trimmed } private decodeBinaryContent(data: Buffer): string { if (data.length === 0) return '' try { if (data.length >= 4 && data.readUInt32LE(0) === 0xfd2fb528) { const fzstd = nodeRequire('fzstd') as { decompress: (input: Buffer) => Uint8Array } const decompressed = fzstd.decompress(data) return Buffer.from(decompressed).toString('utf-8') } } catch { return '' } const decoded = data.toString('utf-8') const replacementCount = (decoded.match(/\uFFFD/g) || []).length if (replacementCount < decoded.length * 0.2 && this.isMostlyReadableText(decoded)) { return decoded.replace(/\uFFFD/g, '') } return '' } private looksLikeHex(value: string): boolean { return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value) } private looksLikeBase64(value: string): boolean { if (value.length % 4 !== 0) return false return /^[A-Za-z0-9+/]+={0,2}$/.test(value) } private isMostlyReadableText(value: string): boolean { if (!value) return false const readable = Array.from(value).filter((char) => { const code = char.charCodeAt(0) return code === 0x09 || code === 0x0a || code === 0x0d || code >= 0x20 }).length return readable / value.length > 0.85 } private pickNumber(row: Record, keys: string[]): number { for (const key of keys) { const value = this.pickValue(row, [key]) const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN if (Number.isFinite(parsed)) { return parsed > 1e12 ? Math.floor(parsed / 1000) : Math.floor(parsed) } } return 0 } private pickBoolean(row: Record, keys: string[]): boolean { for (const key of keys) { const value = this.pickValue(row, [key]) if (typeof value === 'boolean') return value if (typeof value === 'number') return value === 1 if (typeof value === 'string') return value === '1' || value.toLowerCase() === 'true' } return false } private uniq(values: string[]): string[] { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) } getMyUsernameCandidates(): string[] { const rawAccountName = path.basename(this.accountRoot) return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)]) } private normalizeTimestamp(input: number): number { if (!input || input <= 0) return 0 const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input) return Math.min(Math.max(normalized, 0), 2147483647) } private quoteSqlIdentifier(identifier: string): string { return `"${String(identifier || '').replace(/"/g, '""')}"` } }