feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示

- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
This commit is contained in:
电摇小子
2026-07-28 02:45:20 +08:00
parent 9348c5ce4a
commit 7db845ac7e
18 changed files with 1130 additions and 281 deletions
+68 -22
View File
@@ -17,7 +17,7 @@ import { extname } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb } from './wechat-db'
import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client'
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
@@ -61,12 +61,15 @@ import {
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import {
flushBootstrapCacheWritesSync,
getBootstrapCache,
getCachedMessagePage,
getCachedMessages,
mergeBootstrapAvatars,
mergeCachedContactAvatars,
saveBootstrapContacts,
saveBootstrapSelf,
saveCachedGroupSnapshot,
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
@@ -95,16 +98,23 @@ const keyServiceWin = new KeyServiceWin()
let tray: Tray | null = null
let recallArchiveMonitor: RecallArchiveMonitor | null = null
let recallProtectionGeneration = 0
let recallJournalTimer: NodeJS.Timeout | null = null
let wcdbBootstrapPromise: Promise<unknown> | null = null
function configureRecallProtection(
wcdb4Client: Wcdb4Client,
accountRoot: string,
enabled: boolean
enabled: boolean,
installJournal = false
): void {
recallProtectionGeneration += 1
const generation = recallProtectionGeneration
recallArchiveMonitor?.stop()
recallArchiveMonitor = null
if (recallJournalTimer) {
clearTimeout(recallJournalTimer)
recallJournalTimer = null
}
configureRecallArchive(enabled ? accountRoot : '')
if (!enabled) return
@@ -128,8 +138,19 @@ function configureRecallProtection(
})
)
recallArchiveMonitor = monitor
monitor.seedAll()
setTimeout(() => {
// Session indexing is not required to open the UI. Defer it so large databases
// do not make db:init wait for every conversation to be enumerated.
setImmediate(() => {
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
monitor.seedAll()
}
})
if (!installJournal) return
// Creating recall triggers scans every message table and runs synchronously in
// the main process. Only do this after the user explicitly enables protection;
// existing installations remain active without repeating the scan at startup.
recallJournalTimer = setTimeout(() => {
recallJournalTimer = null
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
const result = wcdb4Client.installRecallJournal(
wcdb4Client.getSessions().map((session) => session.username)
@@ -137,7 +158,7 @@ function configureRecallProtection(
console.log(
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
)
}, 0)
}, 30_000)
}
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
@@ -305,15 +326,11 @@ app.whenReady().then(async () => {
})
})
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
// per process. Bootstrap native once here so any later Wcdb4Client instance
// reuses the already-initialized library and skips wcdb_init.
try {
bootstrapWcdbNative()
console.log('[WCDB4] bootstrap complete at whenReady top')
} catch (bootstrapError) {
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
}
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
createWindow()
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
console.log('[WCDB4] async bootstrap complete')
})
// 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.wechatexplorer.app')
@@ -338,6 +355,7 @@ app.whenReady().then(async () => {
dbInitInFlight = (async () => {
try {
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings()
@@ -372,6 +390,13 @@ app.whenReady().then(async () => {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
setImmediate(() => {
const recentSession = wcdb4Client.getSessions()[0]
if (!recentSession?.username) return
void wcdb4Client
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
})
imageDecryptService = null
return { success: true, monitoring }
} catch (error) {
@@ -512,11 +537,26 @@ app.whenReady().then(async () => {
return getBootstrapCache(chat.getCurrentAccountRoot())
})
ipcMain.handle('db:getStartupCache', () => {
const settings = loadSettings()
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
})
ipcMain.handle(
'db:getCachedMessages',
(_, userMd5: string, startTime?: number, endTime?: number) => {
if (!chat.isReady()) return []
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
}
)
ipcMain.handle(
'db:getCachedMessagePage',
(_, userMd5: string, startTime?: number, endTime?: number) => {
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
return accountRoot
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
: { hit: false, messages: [] }
}
)
@@ -539,8 +579,8 @@ app.whenReady().then(async () => {
ipcMain.handle(
'db:getMessages',
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
const messages = chat.listMessages(userMd5, startTime, endTime, options)
async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
}
@@ -548,7 +588,13 @@ app.whenReady().then(async () => {
}
)
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) {
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
}
return snapshot
})
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
@@ -824,7 +870,8 @@ app.whenReady().then(async () => {
configureRecallProtection(
currentDb.getWcdb4Client(),
chat.getCurrentAccountRoot(),
nextSettings.recallProtectionEnabled
nextSettings.recallProtectionEnabled,
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
)
}
}
@@ -938,8 +985,6 @@ app.whenReady().then(async () => {
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
createWindow()
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
const settings = loadSettings()
if (settings.apiEnabled) {
@@ -972,6 +1017,7 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => {
agentHubService.stop()
flushBootstrapCacheWritesSync()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
+92 -4
View File
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
accountRoot: string
}
export interface CachedGroupSnapshot {
roomId: string
memberCount: number
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
interface CachedMessageBucket {
updatedAt: number
startTime?: number
@@ -26,11 +39,12 @@ interface BootstrapCacheFile {
self?: CachedSelfInfo
contacts?: Contact[]
messages?: Record<string, CachedMessageBucket>
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
}
const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24
const MAX_MESSAGES_PER_BUCKET = 1200
const MAX_MESSAGE_BUCKETS = 768
const MAX_MESSAGES_PER_BUCKET = 120
const WRITE_DEBOUNCE_MS = 300
const memoryCache = new Map<string, BootstrapCacheFile>()
const writeTimers = new Map<string, NodeJS.Timeout>()
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
updatedAt: Number(raw.updatedAt) || 0,
self: raw.self,
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
groupSnapshots:
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
}
memoryCache.set(file, result)
return result
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
@@ -132,6 +149,12 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
}
function cachedMessageIdentity(message: Message): string {
if (message.localId) return `local:${message.localId}`
if (message.serverId) return `server:${message.serverId}`
return `id:${message.id}`
}
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
const entries = Object.entries(messages)
if (entries.length <= MAX_MESSAGE_BUCKETS) return
@@ -260,6 +283,71 @@ export function getCachedMessages(
return bucket?.items || []
}
export function getCachedMessagePage(
accountRoot: string,
userMd5: string,
startTime?: number,
endTime?: number
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
const cache = readCacheFile(accountRoot)
const key = messageBucketKey(userMd5, startTime, endTime)
let bucket = cache?.messages?.[key]
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
const merged = new Map<string, Message>()
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
if (!cachedKey.startsWith(`${userMd5}:`)) continue
for (const message of candidate.items || []) {
merged.set(cachedMessageIdentity(message), message)
}
}
const migratedMessages = Array.from(merged.values())
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
.slice(-MAX_MESSAGES_PER_BUCKET)
if (migratedMessages.length > 0) {
bucket = {
updatedAt: Date.now(),
items: migratedMessages
}
cache.messages[key] = bucket
cache.updatedAt = Date.now()
pruneMessageBuckets(cache.messages)
writeCacheFile(cache)
}
}
return {
hit: Boolean(bucket),
messages: bucket?.items || [],
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
}
}
export function saveCachedGroupSnapshot(
accountRoot: string,
userMd5: string,
snapshot: CachedGroupSnapshot
): void {
const cache = loadOrCreate(accountRoot)
if (!cache) return
cache.groupSnapshots ||= {}
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
cache.updatedAt = Date.now()
writeCacheFile(cache)
}
export function flushBootstrapCacheWritesSync(): void {
for (const [file, cache] of memoryCache) {
const timer = writeTimers.get(file)
if (timer) clearTimeout(timer)
writeTimers.delete(file)
try {
fs.ensureDirSync(path.dirname(file))
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
} catch (error) {
console.warn('[BootstrapCache] flush failed:', error)
}
}
}
export function saveCachedMessages(
accountRoot: string,
userMd5: string,
+66 -21
View File
@@ -143,25 +143,29 @@ export function listContacts(filter?: string): FormattedContact[] {
})
}
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
// The session list already covers normal conversations. Only scan Chat_*
// tables as a recovery fallback when the session query returned nothing.
if (userList.length === 0) {
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
}
}
}
return contacts
@@ -180,7 +184,8 @@ function listSourceMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
options?: { limit?: number },
rawMessagesOverride?: WechatMessage[]
): FormattedMessage[] {
if (!dbRef) return []
@@ -191,7 +196,8 @@ function listSourceMessages(
console.log(
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
console.log(
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
@@ -350,6 +356,26 @@ export function listMessages(
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
options,
rawMessages
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
@@ -371,6 +397,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
return { roomId, memberCount: members.length, members }
}
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
groupNickname: member.groupNickname || '',
wechatNickname: member.wechatNickname || '',
remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
+361 -2
View File
@@ -68,6 +68,8 @@ type KoffiModule = {
// library reference for every Wcdb4Client instance.
let wcdbBootstrapLib: KoffiLibrary | null = null
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
if (wcdbBootstrapLib) return wcdbBootstrapLib
@@ -141,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
return lib
}
export function bootstrapWcdbNativeAsync(
libPath?: string,
libDirOverride?: string
): Promise<KoffiLibrary> {
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<void>((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) => (...args: unknown[]) => unknown
func: (signature: string) => KoffiAsyncFunction
}
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
async: (...args: unknown[]) => void
}
type WcdbVoidOut = [unknown]
@@ -170,6 +246,7 @@ export class Wcdb4Client {
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
@@ -428,6 +505,39 @@ export class Wcdb4Client {
}
}
async openAsync(): Promise<void> {
this.loadNativeLibrary()
if (!this.wcdbOpenAccountAsync) {
throw new Error('WCDB 4.0 native open async interface unavailable')
}
const handleOut: WcdbHandleOut = [0]
const openResult = await new Promise<number>((resolve, reject) => {
this.wcdbOpenAccountAsync!.async(
this.sessionDbPath,
this.key,
handleOut,
(error: unknown, result: unknown) => {
if (error) reject(error)
else resolve(Number(result))
}
)
})
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 {
this.wcdbSetMyWxid(this.handle, this.wxid)
} catch {
// Optional helper. Failure does not block message reads.
}
}
}
close(): void {
this.stopMonitor()
if (this.handle === null || !this.wcdbShutdown) return
@@ -699,6 +809,21 @@ export class Wcdb4Client {
return messages
}
async getMessagesAsync(
username: string,
startTime?: number,
endTime?: number,
options: Wcdb4MessageQueryOptions = {}
): Promise<Wcdb4Message[]> {
const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit)
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
console.log(
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
)
return messages
}
private readSessionRows(): Record<string, unknown>[] {
if (!this.wcdbGetSessions) return []
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
@@ -1139,6 +1264,140 @@ export class Wcdb4Client {
}
}
private async getMessagesByCursorAsync(
username: string,
startTime?: number,
endTime?: number,
limit?: number
): Promise<Wcdb4Message[]> {
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<string, unknown>[] = []
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<string, unknown>[]
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<Wcdb4GroupMember[]> {
if (!this.wcdbGetGroupMembers || !chatroomId) return []
try {
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
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<string, string> {
const cached = this.groupNicknameCache.get(chatroomId)
if (cached) return cached
@@ -1167,6 +1426,28 @@ export class Wcdb4Client {
return nicknames
}
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
const cached = this.groupNicknameCache.get(chatroomId)
if (cached) return cached
const nicknames = new Map<string, string>()
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
const rows = await this.callJsonAsync<
Record<string, string> | Record<string, unknown>[]
>(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,
@@ -1354,9 +1635,11 @@ export class Wcdb4Client {
}
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
this.wcdbOpenAccount = lib.func(
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)'
@@ -1646,6 +1929,41 @@ export class Wcdb4Client {
}
}
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
const handle = this.ensureHandle()
const outJson: WcdbVoidOut = [null]
return new Promise<T>((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<number> {
return new Promise<number>((resolve, reject) => {
fn.async(...args, (error: unknown, result: unknown) => {
if (error) reject(error)
else resolve(Number(result))
})
})
}
private ensureHandle(): number {
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
return this.handle
@@ -1810,6 +2128,26 @@ export class Wcdb4Client {
}
}
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
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<string, string> | Record<string, unknown>[]
>(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 hydrateAvatarUrls(usernames: string[]): void {
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
if (missing.length === 0) return
@@ -1844,6 +2182,27 @@ export class Wcdb4Client {
}
}
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
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<string, string> | Record<string, unknown>[]
>(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<string, string> {
const result = new Map<string, string>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
+36 -13
View File
@@ -35,20 +35,12 @@ export interface GroupMemberInfo {
export class WechatDb {
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
private chatTableMappingLoaded = false
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
// WCDB native init must run on the Electron main process; worker threads
// get -1006 from wcdb_init. Initialize the client synchronously here
// (and keep create() async for callers that already await it).
return new Promise((resolve, reject) => {
try {
const client = new Wcdb4Client(rawKey, accountRoot)
client.open()
resolve(new WechatDb(rawKey, accountRoot, client))
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
})
const client = new Wcdb4Client(rawKey, accountRoot)
await client.openAsync()
return new WechatDb(rawKey, accountRoot, client)
}
constructor(
@@ -61,11 +53,22 @@ export class WechatDb {
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
if (!clientOverride) client.open()
this.wcdb4Client = client
for (const table of initialChatTables || client.getChatTables()) {
for (const table of initialChatTables || []) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
this.chatTableMappingLoaded = Boolean(initialChatTables)
}
private ensureChatTableMapping(): void {
if (this.chatTableMappingLoaded) return
for (const table of this.wcdb4Client.getChatTables()) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
this.chatTableMappingLoaded = true
}
public getUserList(nicknameFilter?: string): UserContact[] {
@@ -112,6 +115,7 @@ export class WechatDb {
}
public getGroupMembersForChat(userMd5: string): Record<string, string> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username || !username.endsWith('@chatroom')) return {}
@@ -154,6 +158,7 @@ export class WechatDb {
endTime?: number,
options?: Wcdb4MessageQueryOptions
): WechatMessage[] {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
@@ -162,6 +167,24 @@ export class WechatDb {
}))
}
public async getUserMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: Wcdb4MessageQueryOptions
): Promise<WechatMessage[]> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
const messages = await this.wcdb4Client.getMessagesAsync(
username,
startTime,
endTime,
options
)
return messages.map((message) => ({ ...message, ...message.raw }))
}
public searchAllMessages(keyword: string): string | null {
const lowerKeyword = keyword.trim().toLowerCase()
if (!lowerKeyword) return null