Files
WechatExplorer/src/main/wcdb4-client.ts
T
2026-07-27 20:34:57 +08:00

2051 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
raw: Record<string, unknown>
}
export interface Wcdb4Message {
mesLocalID: string
serverId?: string
mesDes: number
messageType: string
msgCreateTime: string
msgContent: string
sender?: string
senderNickname?: string
senderAvatar?: string
raw: Record<string, unknown>
}
export interface Wcdb4MessageQueryOptions {
limit?: number
}
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
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
}
type KoffiLibrary = {
func: (signature: string) => (...args: unknown[]) => unknown
}
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<string, string>()
private avatarCache = new Map<string, string>()
private groupNicknameCache = new Map<string, Map<string, string>>()
private cachedSessions: Wcdb4Session[] | null = null
private cachedChatTables: { name: string; db_number: string }[] | null = null
private wcdbShutdown: (() => number) | null = null
private wcdbOpenAccount:
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
| 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 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<typeof setTimeout> | null = null
private monitorReconnectTimer: ReturnType<typeof setTimeout> | 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.
}
}
}
close(): void {
this.stopMonitor()
if (this.handle === null || !this.wcdbShutdown) return
if (process.platform !== 'win32') {
try {
this.wcdbShutdown()
} catch {
// Shutdown is best-effort on app close.
}
}
this.handle = null
this.cachedSessions = null
this.displayNameCache.clear()
this.avatarCache.clear()
this.groupNicknameCache.clear()
}
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
this.stopMonitor()
this.monitorCallback = callback
try {
const startResult = this.wcdbStartMonitorPipe()
if (startResult !== 0) {
this.monitorCallback = null
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
return false
}
this.monitorStarted = true
const outName: WcdbVoidOut = [null]
const nameResult = this.wcdbGetMonitorPipeName(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.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
}
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.hydrateDisplayNames(
sessions
.filter((session) => this.shouldHydrateSessionDisplayName(session))
.map((session) => session.username)
)
this.cachedSessions = sessions.map((session) => ({
...session,
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
}))
return this.cachedSessions
}
invalidateSessionCache(): void {
this.cachedSessions = null
this.cachedChatTables = null
}
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<string, unknown>[] = []
const limit = 1000
let offset = 0
while (true) {
let rows: Record<string, unknown>[]
try {
rows = this.callJson<Record<string, unknown>[]>((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
}
private readSessionRows(): Record<string, unknown>[] {
if (!this.wcdbGetSessions) return []
const rows = this.callJson<Record<string, unknown>[]>((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<string, unknown>[] = []
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<Record<string, unknown>[]>((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)
}
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
const stores = new Map<string, Wcdb4MessageStore>()
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<Record<string, unknown>[]>((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<string, unknown>[] {
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
const rows = this.callJson<Record<string, unknown>[]>((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<string, unknown>[] = []
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<string, Wcdb4Message>()
for (const message of [...recovered, ...current]) {
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
const identity = message.mesLocalID
? `local:${message.mesLocalID}`
: message.serverId
? `server:${message.serverId}`
: `${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<string, string> {
const normalized = this.uniq(usernames)
this.hydrateAvatarUrls(normalized)
const result: Record<string, string> = {}
for (const username of normalized) {
const avatar = this.avatarCache.get(username)
if (avatar) result[username] = avatar
}
return result
}
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<string, unknown>[] = []
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<string, unknown>[]
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<string, unknown>[],
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<Record<string, unknown>[]>((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 []
}
}
getGroupNicknames(chatroomId: string): 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
try {
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
(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
}
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<Wcdb4ImageHardlink>((handle, outJson) =>
this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson)
)
} catch (error) {
console.warn('[WCDB4] resolve image hardlink 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<Wcdb4VideoHardlink>((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
this.wcdbOpenAccount = lib.func(
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
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.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<T>(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 ensureHandle(): number {
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
return this.handle
}
private normalizeSession(row: Record<string, unknown>): 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'
])
return { username, nickname, wechatNickname, remark, raw: row }
}
private normalizeMessage(row: Record<string, unknown>): 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<string, string> | Record<string, unknown>[]>(
(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 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<string, string> | Record<string, unknown>[]>(
(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 readContactAvatarUrls(usernames: string[]): Map<string, string> {
const result = new Map<string, string>()
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<Record<string, unknown>[]>((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<string, string> | Record<string, unknown>[],
valueKeys: string[]
): Map<string, string> {
const result = new Map<string, string>()
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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, '""')}"`
}
}