Files
WechatExplorer/src/main/wcdb4-client.ts
T
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00

3793 lines
128 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'
import { wcdbDebugLog } from './wcdb-debug'
import type { ImageMessageCountProbe } from '../shared/image-text-index'
export interface Wcdb4Session {
username: string
nickname: string
avatar?: string
wechatNickname?: string
remark?: string
alias?: string
wechatId?: string
wxid?: string
legacyIdentifier?: string
isFolded?: boolean
isMuted?: boolean
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
}
export interface Wcdb4SessionQueryOptions {
hydrateDisplayNames?: boolean
hydrateStatuses?: boolean
}
export interface WindowsNativePathBridgeOptions {
platform?: NodeJS.Platform
publicRoot?: string
}
type Wcdb4MessageStore = {
tableName: string
dbPath: string
}
export interface Wcdb4GroupMember {
m_nsUsrName: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
m_nsHeadImgUrl: string
}
export type Wcdb4GroupMemberBatchStatus = 'ok' | 'not_found'
export interface Wcdb4GroupMemberIdsBatchItem {
roomId: string
status: Wcdb4GroupMemberBatchStatus
memberWxids: string[]
}
function normalizeGroupMemberIdsBatch(
value: unknown,
requestedRoomIds: string[]
): Wcdb4GroupMemberIdsBatchItem[] | null {
if (!Array.isArray(value) || value.length !== requestedRoomIds.length) return null
const requested = new Set(requestedRoomIds)
const results = new Map<string, Wcdb4GroupMemberIdsBatchItem>()
for (const rawItem of value) {
if (!rawItem || typeof rawItem !== 'object') return null
const item = rawItem as Record<string, unknown>
const roomId = typeof item.roomId === 'string' ? item.roomId.trim() : ''
const status = item.status
if (
!roomId ||
!requested.has(roomId) ||
results.has(roomId) ||
(status !== 'ok' && status !== 'not_found') ||
!Array.isArray(item.memberWxids) ||
item.memberWxids.some((memberWxid) => typeof memberWxid !== 'string' || !memberWxid.trim())
) {
return null
}
results.set(roomId, {
roomId,
status,
memberWxids: Array.from(new Set(item.memberWxids.map((memberWxid) => memberWxid.trim())))
})
}
if (results.size !== requestedRoomIds.length) return null
return requestedRoomIds.map((roomId) => results.get(roomId)!)
}
/**
* 联系人表中独立保存的成员名称字段。
*
* 群成员接口的 nickname 在部分微信数据版本中会被通讯录备注覆盖,因此不能用它
* 推断微信昵称或备注。
*/
type Wcdb4ContactMemberNames = {
wechatNickname: string
remark: string
}
export type Wcdb4ContactIdentity = {
username: string
wechatId?: string
wxid: string
legacyIdentifier?: 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<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
}
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) => KoffiAsyncFunction
}
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
async: (...args: unknown[]) => void
}
type WcdbVoidOut = [unknown]
type WcdbHandleOut = [number]
export interface Wcdb4VoiceDataRequest {
sessionId: string
createTime: number
candidates: string[]
localId?: number
svrId?: string | number
}
export interface Wcdb4VoiceDataResult {
success: boolean
hex?: string
error: string
}
const nodeRequire = createRequire(import.meta.url)
function isAsciiPath(value: string): boolean {
return /^[\x20-\x7e]+$/.test(value)
}
export function resolveWindowsNativeAccountRoot(
accountRoot: string,
options: WindowsNativePathBridgeOptions = {}
): string {
const platform = options.platform || process.platform
if (platform !== 'win32' || isAsciiPath(accountRoot)) return accountRoot
const publicRoot = [
options.publicRoot,
process.env.PUBLIC,
path.join(process.env.SystemDrive || 'C:', 'Users', 'Public')
]
.map((candidate) => String(candidate || '').trim())
.find((candidate) => candidate && isAsciiPath(candidate))
if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot
const bridgeId = crypto
.createHash('sha256')
.update(path.resolve(accountRoot).toLowerCase())
.digest('hex')
.slice(0, 24)
const legacyBridgePath = path.join(publicRoot, 'WechatExplorer', 'path-bridges', bridgeId)
try {
if (fs.existsSync(legacyBridgePath)) {
const legacyTarget = fs.realpathSync.native(legacyBridgePath)
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(legacyTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return legacyBridgePath
}
}
} catch {
// An unreadable legacy junction is left untouched; TraceMemo creates its own bridge below.
}
const bridgeRoot = path.join(publicRoot, 'TraceMemo', 'path-bridges')
const bridgePath = path.join(bridgeRoot, bridgeId)
try {
fs.ensureDirSync(bridgeRoot)
if (fs.existsSync(bridgePath)) {
const existingTarget = fs.realpathSync.native(bridgePath)
const canonicalAccountRoot = fs.realpathSync.native(accountRoot)
if (
path.resolve(existingTarget).toLowerCase() ===
path.resolve(canonicalAccountRoot).toLowerCase()
) {
return bridgePath
}
fs.removeSync(bridgePath)
}
fs.symlinkSync(path.resolve(accountRoot), bridgePath, 'junction')
return bridgePath
} catch (error) {
console.warn('[WCDB4] 无法为中文数据目录建立 native 路径别名:', error)
return accountRoot
}
}
export class Wcdb4Client {
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
private readonly key: string
private readonly accountRoot: string
private readonly nativeAccountRoot: 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 contactIdentityCache = new Map<string, Wcdb4ContactIdentity>()
private avatarCache = new Map<string, string>()
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
private groupNicknameCache = new Map<string, Map<string, string>>()
private cachedSessions: Wcdb4Session[] | null = null
private cachedChatTables: { name: string; db_number: string }[] | null = null
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
private sessionDisplayNamesInFlight: Promise<void> | null = null
private sessionDisplayNamesHydrated = false
private sessionStatusesInFlight: Promise<void> | null = null
private sessionStatusesUpdatedAt = 0
private sessionCacheGeneration = 0
private closing = false
private nativeCallsInFlight = new Set<Promise<unknown>>()
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 wcdbGetGroupMembersBatch:
| ((handle: number, chatroomIdsJson: 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 wcdbGetVoiceDataBatch: KoffiAsyncFunction | 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.nativeAccountRoot = resolveWindowsNativeAccountRoot(this.accountRoot)
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
this.dbStoragePath = path.join(this.nativeAccountRoot, '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 = [
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 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<void> {
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<boolean> {
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.contactIdentityCache?.clear()
this.avatarCache.clear()
this.sessionStatusCache.clear()
this.groupNicknameCache.clear()
}
private waitForNativeCalls(timeoutMs: number): Promise<boolean> {
const pending = Array.from(this.nativeCallsInFlight)
if (pending.length === 0) return Promise.resolve(true)
return new Promise<boolean>((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<boolean> {
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
}
getContactIdentity(username: string): Wcdb4ContactIdentity | undefined {
return this.contactIdentityCache.get(username)
}
async hydrateContactIdentitiesAsync(usernames: string[]): Promise<void> {
if (!this.wcdbExecQuery || usernames.length === 0) return
const missing = this.uniq(usernames).filter(
(username) => !this.contactIdentityCache.has(username)
)
if (missing.length === 0) return
const inList = missing.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'contact',
'',
`SELECT * FROM contact WHERE username IN (${inList})`
)
for (const identity of this.contactIdentitiesFromRows(rows)) {
this.contactIdentityCache.set(identity.username, identity)
}
} catch {
// Older native builds may not expose the contact table.
}
}
async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise<Wcdb4Session[]> {
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<Wcdb4Session[]> => {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
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<void> {
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<void> => {
try {
const map = await this.callJsonAsync<
Record<string, { isFolded?: boolean; isMuted?: boolean }>
>(
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
// Session rows can change their folded/muted flags independently of the
// contact list. Invalidate the status snapshot together with sessions so
// the next hydrated contact load reads the current WeChat state.
this.sessionStatusCache.clear()
this.sessionStatusesUpdatedAt = 0
this.sessionDisplayNamesHydrated = false
this.contactIdentityCache?.clear()
}
invalidateAvatarCache(usernames?: string[]): void {
if (!usernames) {
this.avatarCache.clear()
return
}
for (const username of this.uniq(usernames)) this.avatarCache.delete(username)
}
/** 清理群备注缓存,不影响会话缓存。 */
invalidateGroupNicknameCache(): void {
this.groupNicknameCache.clear()
}
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 && cursorMessages.length > 0) {
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
}
async getMessagesAsync(
username: string,
startTime?: number,
endTime?: number,
options: Wcdb4MessageQueryOptions = {},
requestId = 'NO-REQUEST'
): Promise<Wcdb4Message[]> {
const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit)
wcdbDebugLog(
`[${requestId}] WCDB getMessagesAsync start username=${username} maxRows=${maxRows || 0} start=${startTime || 0} end=${endTime || 0}`
)
let cursorMessages: Wcdb4Message[] = []
try {
cursorMessages = await this.getMessagesByCursorAsync(
username,
startTime,
endTime,
maxRows,
requestId
)
} 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[] = []
const cursorSatisfiesLimit = maxRows !== undefined && cursorMessages.length >= maxRows
let tableScanExecuted = false
if (!cursorSatisfiesLimit && (endTime || cursorMessages.length === 0)) {
tableScanExecuted = true
tableMessages = await this.getMessagesByTableScanAsync(
username,
startTime,
endTime,
maxRows,
requestId
)
}
wcdbDebugLog(
`[${requestId}] WCDB message source decision cursorSatisfiesLimit=${cursorSatisfiesLimit} tableScanExecuted=${tableScanExecuted} cursorRows=${cursorMessages.length} tableRows=${tableMessages.length}`
)
const mergeStartedAt = Date.now()
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
wcdbDebugLog(
`[${requestId}] WCDB mergeMessageRows cost=${Date.now() - mergeStartedAt}ms finalRows=${messages.length}`
)
wcdbDebugLog(
`[${requestId}] WCDB getMessagesAsync end username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} totalCost=${Date.now() - startedAt}ms`
)
return messages
}
async countVoiceMessagesAsync(
md5OrUsername: string,
startTime?: number,
endTime?: number
): Promise<number | null> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return null
// 与图片计数同因的修正:同一个 md5/username 混淆在这里也存在,
// 而且它更隐蔽 —— 匹配不到表时循环不执行,函数会**返回 0 而不是报错**。
const username = this.resolveMessageUsername(md5OrUsername)
if (!username) return null
let tables: Wcdb4MessageStore[]
try {
tables = await this.listMessageStoresAsync(username)
} catch (error) {
console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error)
return null
}
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const where = [
'(("local_type" & 65535) = 34)',
begin > 0 ? `"create_time" >= ${begin}` : '',
end > 0 ? `"create_time" <= ${end}` : ''
].filter(Boolean)
let total = 0
for (const table of tables) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
`SELECT COUNT(*) AS "voice_count" FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${where.join(' AND ')}`
)
const value = Number(this.pickValue(rows[0] || {}, ['voice_count', 'count', 'COUNT(*)']))
if (Number.isFinite(value)) total += value
} catch (error) {
console.warn(
`[WCDB4] voice count failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
error
)
return null
}
}
return total
}
/**
* 消息类型列的可能名字(按顺序探测,命中即用)。
*
* 为什么不能直接硬编码 `"local_type"`:`pickValue(row, [...别名])` 那套别名列表只作用于
* **已经读出来的行**;一旦把列名写进 WHERE,列名不同的库会当场抛错,再被 catch 吞成
* `null` —— 表现就是"检测到 0 张图片"。所以必须先探测真实列名。
*/
private readonly messageTypeColumnCandidates = [
'local_type',
'localType',
'msg_type',
'msgType',
'message_type',
'messageType',
'type',
'WCDB_CT_local_type'
]
/** 每个消息分片的真实类型列名;探测一次即缓存,避免每个会话都跑一次 PRAGMA。 */
private readonly messageTypeColumnCache = new Map<string, string | null>()
private resolveMessageTypeColumn(store: Wcdb4MessageStore): string | null {
const cacheKey = `${store.dbPath}\u0000${store.tableName}`
const cached = this.messageTypeColumnCache.get(cacheKey)
if (cached !== undefined) return cached
let resolved: string | null = null
try {
const columns = this.readMessageColumns(store).map((column) => column.name)
for (const candidate of this.messageTypeColumnCandidates) {
const hit = columns.find((name) => name.toLowerCase() === candidate.toLowerCase())
if (hit) {
resolved = hit
break
}
}
} catch {
resolved = null
}
this.messageTypeColumnCache.set(cacheKey, resolved)
return resolved
}
/**
* 把「会话 md5」解析成原生接口真正需要的 username。
*
* `contact.md5` 是 `md5(wxid)` 的**哈希**(见 chat-service 的 `dbRef.md5(user.m_nsUsrName)`),
* 而 `wcdbGetMessageTableStats` / `wcdbGetMessages` 这些原生接口要的是**原始 username**。
* 直接把 md5 当 username 传,原生侧匹配不到任何表 —— 表现为"未找到该会话的消息表",
* 而按表统计的计数会静默变成 0。
*
* 既有读消息路径一直做了这层转换(`listSourceMessages` 里的 `getUsernameByMd5`),
* **统计/水位路径漏了**,所以这里统一补上。
*
* 解析不到时原样返回:调用方本来就传 username 的路径仍然可用。
*/
private resolveMessageUsername(md5OrUsername: string): string {
const value = String(md5OrUsername || '').trim()
if (!value) return value
const bySession = this.getUsernameByMd5(value)
if (bySession) return bySession
// 有些群只以 `Chat_<md5>` 表存在、不在 session 列表里;退回按聊天表映射解析
// (与 wechat-db 的 `chatMd5ToUsername` 同一套依据)。
try {
const byChatTable = this.getChatTables().find((table) => table.name === `Chat_${value}`)
if (byChatTable?.db_number) return byChatTable.db_number
} catch {
// 映射不可用时退回原值。
}
return value
}
/** 图片消息的 WHERE 片段;`sinceMs` 用于只统计某个时间点之后的消息(测试小窗口)。 */
private imageMessageWhere(column: string, sinceMs?: number): string {
const clauses = [`(${this.quoteSqlIdentifier(column)} & 65535) = 3`]
// 微信的 create_time 是**秒**,调用方给的是毫秒。
if (sinceMs && Number.isFinite(sinceMs) && sinceMs > 0) {
clauses.push(`"create_time" >= ${Math.floor(sinceMs / 1000)}`)
}
return clauses.join(' AND ')
}
/**
* 统计图片消息条数。
*
* 与 `countVoiceMessagesAsync` 同构:纯 SQL COUNT,**不解密任何图片** ——
* 这是「点击索引前先告诉用户有多少张图片」能足够快的前提。
*
* 与语音版本的关键差别:这里**必须区分「0 张」与「统计失败」**。
* `count: null` 表示没数成,调用方绝不能把它当成 0。
*/
async countImageMessagesAsync(
md5OrUsername: string,
sinceMs?: number
): Promise<ImageMessageCountProbe> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) {
return { count: null, typeColumn: null, error: '当前数据服务不支持消息表统计' }
}
const username = this.resolveMessageUsername(md5OrUsername)
if (!username) {
return { count: null, typeColumn: null, error: '无法解析该会话的标识' }
}
let tables: Wcdb4MessageStore[]
try {
tables = await this.listMessageStoresAsync(username)
} catch {
return { count: null, typeColumn: null, error: '读取消息分片失败' }
}
if (!tables.length) {
return { count: null, typeColumn: null, error: '未找到该会话的消息表' }
}
let total = 0
let typeColumn: string | null = null
for (const table of tables) {
const column = this.resolveMessageTypeColumn(table)
if (!column) {
return { count: null, typeColumn: null, error: '消息表缺少可识别的消息类型列' }
}
if (!typeColumn) typeColumn = column
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
`SELECT COUNT(*) AS "image_count" FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${this.imageMessageWhere(column, sinceMs)}`
)
const value = Number(this.pickValue(rows[0] || {}, ['image_count', 'count', 'COUNT(*)']))
if (Number.isFinite(value)) total += value
} catch {
return { count: null, typeColumn: null, error: '图片消息统计查询失败' }
}
}
return { count: total, typeColumn }
}
/**
* 图片消息的增量水位:`count` + `max(local_id)`。
*
* 为什么不能只靠 `countImageMessagesAsync`:
* 图片总数相同**不代表**图片集合没变。撤回一张旧图 + 新增一张新图,count 不变,
* 但新图的 `local_id` 更大。只看 count 会静默跳过该会话,新图片永远搜不到。
*
* `local_id` 是 WCDB 每张消息表内的插入序(自增),所以:
* - 任何 append → `max_local_id` 严格变大;
* - 「删旧 + 增新」且总数不变 → `max_local_id` 也变大,照样被发现;
* - 只有「删掉非最大的那张且不新增」才不变,而此时集合缩小、无需重扫。
*
* 仍然是一条 SQL 聚合,**不解密任何图片**,成本与 count 同量级。
*/
async imageConversationWatermarkAsync(
md5OrUsername: string,
sinceMs?: number
): Promise<{ count: number; maxLocalId: number } | null> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return null
// 与计数同因:必须先把会话 md5 解析成原生接口要的 username,否则永远匹配不到消息表。
const username = this.resolveMessageUsername(md5OrUsername)
if (!username) return null
let tables: Wcdb4MessageStore[]
try {
tables = await this.listMessageStoresAsync(username)
} catch {
return null
}
let count = 0
let maxLocalId = 0
for (const table of tables) {
// 同样探测真实列名:硬编码列名会让水位查询静默失败,进而退化成"永远重扫"或"永远跳过"。
const column = this.resolveMessageTypeColumn(table)
if (!column) return null
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
`SELECT COUNT(*) AS "image_count", MAX("local_id") AS "image_max_local_id" FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${this.imageMessageWhere(column, sinceMs)}`
)
const row = rows[0] || {}
const tableCount = Number(this.pickValue(row, ['image_count', 'count', 'COUNT(*)']))
const tableMax = Number(
this.pickValue(row, ['image_max_local_id', 'max_local_id', 'MAX("local_id")'])
)
if (Number.isFinite(tableCount)) count += tableCount
if (Number.isFinite(tableMax) && tableMax > maxLocalId) maxLocalId = tableMax
} catch (error) {
console.warn(
`[WCDB4] image watermark failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
error
)
return null
}
}
return { count, maxLocalId }
}
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.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)
}
/**
* 只读**图片消息**,供图片文字索引使用。
*
* 为什么需要它:`getMessagesAsync` 会把整个会话的消息都读出来,
* 一个 20 万条消息的会话要花十几秒(实测 `rawReadMs≈15s`),
* 而图片索引只关心其中的图片 —— 那是错误的数据边界。
*
* 实现上刻意**复用 `finalizeMessages`**(即 `normalizeMessage` + 群昵称解析 + 排序),
* 这样产出的 `Wcdb4Message` 与全量路径**逐字段同构**,`messageId` / `contentData`
* 语义完全一致 —— 否则 artifact / binding / checkpoint 的键会全变。
* 变化的只有"读哪些行":靠 `imageMessageWhere` 在 SQL 层过滤。
*
* 这里仍是一次性读完该会话的图片(**未分页**):行数由图片数量决定而不是消息数量,
* 已经比全量小一到两个数量级。超过 `limit` 会被截断并告警,调用方应改成分页。
*/
async listImageMessagesAsync(
md5OrUsername: string,
options: { sinceMs?: number; limit?: number; requestId?: string } = {}
): Promise<Wcdb4Message[]> {
if (!this.wcdbExecQuery) return []
const requestId = options.requestId ?? 'NO-REQUEST'
const username = this.resolveMessageUsername(md5OrUsername)
if (!username) return []
const startedAt = Date.now()
let tables: Wcdb4MessageStore[] = []
try {
tables = await this.listMessageStoresAsync(username)
} catch (error) {
console.warn('[WCDB4] image message table stats failed:', error)
return []
}
if (!tables.length) return []
const limit = Math.max(1, options.limit ?? 200_000)
const allRows: Record<string, unknown>[] = []
let successfulTables = 0
for (const table of tables) {
// 真实类型列名必须逐表探测:硬编码会让过滤静默失效,把全量消息当图片读回来。
const column = this.resolveMessageTypeColumn(table)
if (!column) continue
try {
const where = this.imageMessageWhere(column, options.sinceMs)
// `local_id` 参与排序:`create_time` 同秒的消息需要一个稳定次序,
// 否则多次读取的行序可能不同,调用方无法做稳定游标。
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${where} ORDER BY "create_time" ASC, "local_id" ASC LIMIT ${limit}`
const queryStartedAt = Date.now()
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
sql
)
successfulTables += 1
if (Array.isArray(rows)) allRows.push(...rows)
wcdbDebugLog(
`[${requestId}] WCDB image messages table=${table.tableName} rows=${Array.isArray(rows) ? rows.length : 0} cost=${Date.now() - queryStartedAt}ms`
)
} catch (error) {
console.warn(
`[WCDB4] image message scan failed table=${table.tableName}:`,
error
)
}
}
if (tables.length > 0 && successfulTables === 0) return []
const messages = this.finalizeMessages(username, allRows)
if (allRows.length >= limit) {
// 不静默丢数据:截断会让该会话被标成"处理完了",下一遍靠水位修正。
console.warn(`[WCDB4] image message scan truncated rows=${allRows.length} limit=${limit}`)
}
wcdbDebugLog(
`[${requestId}] WCDB image messages end rows=${messages.length} cost=${Date.now() - startedAt}ms`
)
return messages
}
private async getMessagesByTableScanAsync(
username: string,
startTime?: number,
endTime?: number,
limit?: number,
requestId = 'NO-REQUEST'
): Promise<Wcdb4Message[]> {
if (!this.wcdbExecQuery) return []
const startedAt = Date.now()
wcdbDebugLog(`[${requestId}] WCDB table scan start username=${username}`)
let tables: Wcdb4MessageStore[] = []
try {
const statsStartedAt = Date.now()
tables = await this.listMessageStoresAsync(username)
wcdbDebugLog(
`[${requestId}] WCDB get_message_table_stats end stores=${tables.length} cost=${Date.now() - statsStartedAt}ms`
)
} 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<string, unknown>[] = []
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 queryStartedAt = Date.now()
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
sql
)
wcdbDebugLog(
`[${requestId}] WCDB exec_query table=${table.tableName} db=${table.dbPath} rows=${Array.isArray(rows) ? rows.length : 0} cost=${Date.now() - queryStartedAt}ms`
)
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('历史消息分片均读取失败,请检查数据目录或微信数据版本')
}
const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
wcdbDebugLog(
`[${requestId}] WCDB table scan end rows=${result.length} cost=${Date.now() - startedAt}ms`
)
return result
}
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[] {
let stores: Wcdb4MessageStore[] = []
if (this.wcdbGetMessageTableStats) {
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
stores = this.parseMessageStores(rows)
} catch (error) {
if (!username.startsWith('gh_')) throw error
}
}
return stores.length > 0 ? stores : this.listBizMessageStores(username)
}
private async listMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
let stores: Wcdb4MessageStore[] = []
if (this.wcdbGetMessageTableStats) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
username
)
stores = this.parseMessageStores(rows)
} catch (error) {
if (!username.startsWith('gh_')) throw error
}
}
return stores.length > 0 ? stores : this.listBizMessageStoresAsync(username)
}
private parseMessageStores(rows: Record<string, unknown>[]): Wcdb4MessageStore[] {
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 getBizMessageDatabasePaths(): string[] {
const messageRoot = path.join(this.dbStoragePath, 'message')
try {
return fs
.readdirSync(messageRoot)
.filter((name) => /^biz_message(?:_\d+)?\.db$/i.test(name))
.sort()
.map((name) => path.join(messageRoot, name))
} catch {
return []
}
}
private listBizMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return []
const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) {
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(
handle,
'message',
dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`,
outJson
)
)
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath })
} catch {
// Older biz shards may be absent or use a different key; continue scanning.
}
}
return stores
}
private async listBizMessageStoresAsync(username: string): Promise<Wcdb4MessageStore[]> {
if (!this.wcdbExecQuery || !username.startsWith('gh_')) return []
const tableName = `Msg_${this.md5(username)}`
const escapedTableName = tableName.replace(/'/g, "''")
const stores: Wcdb4MessageStore[] = []
for (const dbPath of this.getBizMessageDatabasePaths()) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
dbPath,
`SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`
)
if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath })
} catch {
// Older biz shards may be absent or use a different key; continue scanning.
}
}
return stores
}
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 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<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
}
async getAvatarUrlsAsync(usernames: string[]): Promise<Record<string, string>> {
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<Record<string, string>>(
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<string, string> = {}
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<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))
// Message rows carry a sender id, while the group-local display name lives
// in the separate group nickname map. Resolve that map once per group
// snapshot so chat bubbles do not fall back to the contact nickname.
const groupNicknames = username.endsWith('@chatroom')
? this.getGroupNicknames(username)
: undefined
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 =
groupNicknames?.get(message.sender) ||
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 memberRows = Array.isArray(rows) ? rows : []
const contactNames = this.readContactMemberNames(this.groupMemberUsernames(memberRows))
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
this.hydrateAvatarUrls(
members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
)
return this.withCachedGroupMemberAvatars(members)
} catch {
return []
}
}
private async getMessagesByCursorAsync(
username: string,
startTime?: number,
endTime?: number,
limit?: number,
requestId = 'NO-REQUEST'
): 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
wcdbDebugLog(
`[${requestId}] WCDB open_message_cursor start username=${username} batchSize=${batchSize} ascending=${ascending} begin=${begin} end=${end}`
)
const openStartedAt = Date.now()
const openResult = await this.callAsyncCode(
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
handle,
username,
batchSize,
ascending,
begin,
end,
cursorOut
)
wcdbDebugLog(
`[${requestId}] WCDB open_message_cursor end code=${openResult} cursor=${cursorOut[0]} cost=${Date.now() - openStartedAt}ms`
)
if (openResult !== 0 || cursorOut[0] <= 0) return []
const cursor = cursorOut[0]
const allRows: Record<string, unknown>[] = []
let fetchIndex = 0
try {
while (true) {
fetchIndex += 1
const outJson: WcdbVoidOut = [null]
const outHasMore: [number] = [0]
const fetchStartedAt = Date.now()
const fetchResult = await this.callAsyncCode(
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
handle,
cursor,
outJson,
outHasMore
)
const fetchNativeCost = Date.now() - fetchStartedAt
if (fetchResult !== 0 || !outJson[0]) {
wcdbDebugLog(
`[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms output=${Boolean(outJson[0])}`
)
break
}
try {
const decodeStartedAt = Date.now()
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)
wcdbDebugLog(
`[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms jsonCost=${Date.now() - decodeStartedAt}ms batchRows=${Array.isArray(batch) ? batch.length : 0} hasMore=${outHasMore[0]}`
)
} finally {
this.wcdbFreeString?.(outJson[0])
}
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
}
} finally {
const closeStartedAt = Date.now()
try {
this.wcdbCloseMessageCursor?.(handle, cursor)
} catch {
// Best-effort cursor cleanup.
}
wcdbDebugLog(
`[${requestId}] WCDB close_message_cursor cursor=${cursor} cost=${Date.now() - closeStartedAt}ms fetches=${fetchIndex}`
)
}
const finalizeStartedAt = Date.now()
const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
wcdbDebugLog(
`[${requestId}] WCDB finalizeMessages cost=${Date.now() - finalizeStartedAt}ms rows=${result.length}`
)
return result
}
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 memberRows = Array.isArray(rows) ? rows : []
const contactNames = await this.readContactMemberNamesAsync(
this.groupMemberUsernames(memberRows)
)
const members = this.normalizeGroupMembers(memberRows, groupNicknames, contactNames)
const missingAvatars = members
.filter((member) => !member.m_nsHeadImgUrl)
.map((member) => member.m_nsUsrName)
.filter(Boolean)
await this.hydrateAvatarUrlsAsync(missingAvatars)
return this.withCachedGroupMemberAvatars(members)
} catch (error) {
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
return []
}
}
/**
* 退群检测热路径:只读取原始成员行并提取 wxid,不补群昵称、联系人资料或头像。
* null 表示读取失败,避免调用方把失败误判为成员列表为空。
*/
async getGroupMemberIdsAsync(chatroomId: string): Promise<string[] | null> {
if (!this.wcdbGetGroupMembers || !chatroomId) return null
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
chatroomId
)
const memberRows = Array.isArray(rows) ? rows : []
return Array.from(new Set(this.groupMemberUsernames(memberRows)))
} catch (error) {
console.warn(`[WCDB4] async group member ids failed chatroom=${chatroomId}:`, error)
return null
}
}
/**
* 只取「成员显示名」的轻量路径(Evidence sender enrichment 专用)。
*
* 与 `getGroupMembersAsync` 的关键差别是它**不做**重活:不 materialize 整群成员行、
* 不 hydrate 任何头像、contact 表只查**请求到的** wxid。只做两件必要的事 ——
* 群昵称表(native 没有"按成员过滤"的接口,但有 client 级缓存)+ 少量 contact 名称。
*
* 名称字段与 `normalizeGroupMembers` 同源同优先级
* (`nickname = wechatNickname || groupNickname || username`),所以调用方复用同一套
* 显示名规则时,语义不会比完整快照差。
*/
async getGroupMemberNamesAsync(
chatroomId: string,
usernames: string[]
): Promise<Wcdb4GroupMember[]> {
if (!chatroomId || !chatroomId.endsWith('@chatroom')) return []
const requested = this.uniq(usernames.filter(Boolean))
if (requested.length === 0) return []
try {
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
const contactNames = await this.readContactMemberNamesAsync(requested)
return requested.map((username) => {
const groupNickname = groupNicknames.get(username) || ''
const contact = contactNames.get(username)
const wechatNickname = contact?.wechatNickname || ''
const remark = contact?.remark || ''
return {
m_nsUsrName: username,
nickname: wechatNickname || groupNickname || username,
groupNickname,
wechatNickname,
remark,
// 刻意留空:Evidence 只需要 displayName。头像若将来需要,另走 lazy UI 路径,
// 绝不让 Query Tool 为"可能显示头像"付整群 hydration 的成本。
m_nsHeadImgUrl: ''
}
})
} catch (error) {
console.warn(`[WCDB4] async group member names failed chatroom=${chatroomId}:`, error)
return []
}
}
isGroupMemberIdsBatchAvailable(): boolean {
return Boolean(this.wcdbGetGroupMembersBatch)
}
async getGroupMemberIdsBatchAsync(
roomIds: string[]
): Promise<Wcdb4GroupMemberIdsBatchItem[] | null> {
const requestedRoomIds = Array.from(
new Set(
(Array.isArray(roomIds) ? roomIds : [])
.map((roomId) => String(roomId || '').trim())
.filter(Boolean)
)
)
if (requestedRoomIds.length === 0) return []
if (!this.wcdbGetGroupMembersBatch) return null
try {
const raw = await this.callJsonAsync<unknown>(
this.wcdbGetGroupMembersBatch as unknown as KoffiAsyncFunction,
JSON.stringify(requestedRoomIds)
)
return normalizeGroupMemberIdsBatch(raw, requestedRoomIds)
} catch (error) {
console.warn('[WCDB4] async group member ids batch failed:', error)
return null
}
}
/**
* 将群成员接口与联系人表组装为语义明确的成员快照。
*
* 群昵称只接受群聊数据;微信昵称与通讯录备注只接受 contact 表。即使群成员
* 接口返回了 nickname,也不能作为名称字段的回退,以免把通讯录备注误展示为微信昵称。
*/
private normalizeGroupMembers(
rows: Record<string, unknown>[],
groupNicknames: Map<string, string>,
contactNames: Map<string, Wcdb4ContactMemberNames>
): Wcdb4GroupMember[] {
return rows.map((row) => {
const username = this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
const rowGroupNickname = this.pickString(row, [
'groupNickname',
'group_nickname',
'displayName',
'display_name'
])
const groupNickname = groupNicknames.get(username) || rowGroupNickname
const contact = contactNames.get(username)
const wechatNickname = contact?.wechatNickname || ''
const remark = contact?.remark || ''
const avatar = this.pickString(row, [
'avatarUrl',
'avatar_url',
'headImgUrl',
'm_nsHeadImgUrl'
])
if (username && avatar) this.avatarCache.set(username, avatar)
return {
m_nsUsrName: username,
// nickname 是旧调用方使用的通用显示字段,保持安全的名称优先级。
nickname: wechatNickname || groupNickname || username,
groupNickname,
wechatNickname,
remark,
m_nsHeadImgUrl: avatar
}
})
}
/**
* 从原始群成员行提取联系人查询键,避免把空值传进 SQL IN 条件。
*/
private groupMemberUsernames(rows: Record<string, unknown>[]): string[] {
return rows
.map((row) =>
this.pickString(row, [
'username',
'userName',
'user_name',
'member_username',
'm_nsUsrName'
])
)
.filter(Boolean)
}
/**
* 为已标准化的成员补充头像缓存,不参与名称字段的回退。
*/
private withCachedGroupMemberAvatars(members: Wcdb4GroupMember[]): Wcdb4GroupMember[] {
return members.map((member) => ({
...member,
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
}))
}
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
}
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,
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 fn = this.wcdbGetVoiceData as unknown as KoffiAsyncFunction
return this.createTrackedNativeCall<Wcdb4VoiceDataResult>((resolve, reject) => {
const outHex: WcdbVoidOut = [null]
fn.async(
handle,
sessionId,
createTime,
localId,
BigInt(svrId || 0),
JSON.stringify(candidates),
outHex,
(error: unknown, code: unknown) => {
if (error) {
reject(error)
return
}
try {
const result = Number(code)
if (result !== 0 || !outHex[0]) {
resolve({ success: false, error: `获取语音数据失败: ${result}` })
return
}
const hex = this.decodeHexPtr(outHex[0])
resolve(
hex === null
? { success: false, error: '解析语音数据失败' }
: { success: true, hex, error: '' }
)
} finally {
this.wcdbFreeString?.(outHex[0])
}
}
)
})
}
async getVoiceDataBatch(requests: Wcdb4VoiceDataRequest[]): Promise<Wcdb4VoiceDataResult[]> {
if (!requests.length) return []
if (!this.wcdbGetVoiceDataBatch) {
return this.getVoiceDataIndividually(requests)
}
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbGetVoiceDataBatch,
JSON.stringify(
requests.map((request, index) => ({
session_id: request.sessionId,
create_time: request.createTime,
local_id: request.localId || 0,
svr_id: String(request.svrId || 0),
candidates: request.candidates,
index
}))
)
)
const byIndex = new Map(
(Array.isArray(rows) ? rows : []).map((row) => [Number(row.index), row])
)
return requests.map((_, index) => {
const row = byIndex.get(index)
const rawHex = row?.hex ?? row?.data
const rawSuccess = row?.success ?? row?.Success
const hex = typeof rawHex === 'string' ? rawHex : undefined
const success = (rawSuccess === true || Number(rawSuccess) === 1) && Boolean(hex)
return {
success,
hex: success ? hex : undefined,
error:
typeof row?.error === 'string' && row.error
? row.error
: success
? ''
: '获取语音数据失败'
}
})
} catch (error) {
console.warn('[WCDB4] batch voice lookup failed, using single-item fallback:', error)
return this.getVoiceDataIndividually(requests)
}
}
private async getVoiceDataIndividually(
requests: Wcdb4VoiceDataRequest[]
): Promise<Wcdb4VoiceDataResult[]> {
const results: Wcdb4VoiceDataResult[] = []
for (const request of requests) {
results.push(
await this.getVoiceData(
request.sessionId,
request.createTime,
request.candidates,
request.localId,
request.svrId
)
)
}
return results
}
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.nativeAccountRoot, outJson)
)
} catch (error) {
console.warn('[WCDB4] resolve image hardlink failed:', error)
return null
}
}
async resolveImageHardlinkAsync(md5: string): Promise<Wcdb4ImageHardlink | null> {
if (!this.wcdbResolveImageHardlink) return null
const normalizedMd5 = String(md5 || '')
.trim()
.toLowerCase()
if (!normalizedMd5) return null
try {
return await this.callJsonAsync<Wcdb4ImageHardlink>(
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
normalizedMd5,
this.nativeAccountRoot
)
} 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<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
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.wcdbGetGroupMembersBatch = lib.func(
'int32 wcdb_get_group_members_batch(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)'
) as (handle: number, chatroomIdsJson: string, outJson: WcdbVoidOut) => number
} catch {
this.wcdbGetGroupMembersBatch = 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.wcdbGetVoiceDataBatch = lib.func(
'int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)'
)
} catch {
this.wcdbGetVoiceDataBatch = 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.nativeAccountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'),
path.join(this.nativeAccountRoot, 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 callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
const handle = this.ensureHandle()
const outJson: WcdbVoidOut = [null]
return this.createTrackedNativeCall<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 this.createTrackedNativeCall<number>((resolve, reject) => {
fn.async(...args, (error: unknown, result: unknown) => {
if (error) reject(error)
else resolve(Number(result))
})
})
}
private createTrackedNativeCall<T>(
executor: (resolve: (value: T) => void, reject: (reason?: unknown) => void) => void
): Promise<T> {
if (this.closing) return Promise.reject(new Error('微信数据库正在关闭'))
const promise = new Promise<T>(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<string, unknown>): Wcdb4Session {
const username = this.pickString(row, [
'username',
'user_name',
'userName',
'm_nsUsrName',
'usrName',
'UsrName',
'talker',
'talker_id',
'talkerId',
'sessionId',
'session_id'
])
const nickname = this.pickString(row, [
'nickname',
'nickName',
'displayName',
'display_name',
'remark',
'm_nsNickName',
'name'
])
const wechatNickname = this.pickString(row, [
'wechatNickname',
'wechat_nickname',
'nickname',
'nickName',
'm_nsNickName',
'name'
])
const remark = this.pickString(row, [
'remark',
'remarkName',
'remark_name',
'contactRemark',
'contact_remark'
])
const alias = this.pickString(row, ['alias', 'aliasName', 'alias_name', 'm_nsAlias'])
const wechatId = this.pickString(row, [
'wechatId',
'wechat_id',
'wechatID',
'wxAccount',
'wx_account',
'account'
])
const wxid = this.pickString(row, ['wxid', 'wx_id', 'internalWxid']) || username
const legacyIdentifier = this.pickString(row, [
'legacyIdentifier',
'legacy_identifier',
'originalIdentifier',
'original_identifier',
'originalUsername',
'original_username',
'initialIdentifier',
'initial_identifier'
])
const status = this.sessionStatusCache.get(username)
return {
username,
nickname,
wechatNickname,
remark,
alias,
wechatId: wechatId || alias,
wxid,
legacyIdentifier: legacyIdentifier || undefined,
isFolded: status?.isFolded,
isMuted: status?.isMuted,
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 contentSender = this.extractSenderFromMessageContent(content)
const sender =
this.pickString(row, [
'sender_username',
'senderUsername',
'sender',
'fromUsername',
'from_username',
'WCDB_CT_sender_username'
]) || contentSender
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 rawIsSend = this.pickValue(row, [
'computed_is_send',
'computedIsSend',
'is_send',
'isSend',
'mesDes',
'WCDB_CT_is_send'
])
const parsedIsSend =
rawIsSend === undefined || rawIsSend === null
? false
: this.pickBoolean(row, [
'computed_is_send',
'computedIsSend',
'is_send',
'isSend',
'mesDes',
'WCDB_CT_is_send'
])
const isSelfSender = sender
? this.getMyUsernameCandidates().some((candidate) => {
const left = String(candidate || '')
.trim()
.toLowerCase()
const right = String(sender || '')
.trim()
.toLowerCase()
return Boolean(
left &&
right &&
(left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`))
)
})
: false
const isSend = isSelfSender || parsedIsSend
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
}
}
/** 解决群里自己的消息被识别成 user */
private extractSenderFromMessageContent(content: string): string {
const firstLine = String(content || '')
.split(/\r?\n/, 1)[0]
.trim()
if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return ''
const candidate = firstLine.replace(/:$/, '').trim()
if (!candidate || /\s/.test(candidate)) return ''
if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_'))
return candidate
return this.getMyUsernameCandidates().includes(candidate) ? candidate : ''
}
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 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 async ensureSessionDisplayNamesAsync(): Promise<void> {
if (this.sessionDisplayNamesHydrated || !this.cachedSessions) return
if (this.sessionDisplayNamesInFlight) return this.sessionDisplayNamesInFlight
const generation = this.sessionCacheGeneration
const sessions = this.cachedSessions
const request = (async (): Promise<void> => {
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<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 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.
}
}
/**
* 从 contact 表读取群成员的微信昵称与通讯录备注。
*
* 查询失败时返回空映射:群成员快照仍可用,并由调用方回退到群昵称或 wxid;
* 不得使用群成员接口的 nickname,因为该字段可能实际保存的是通讯录备注。
*/
private readContactMemberNames(usernames: string[]): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(
handle,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`,
outJson
)
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* 异步读取 contact 表的成员名称字段,语义与同步路径保持一致。
*/
private async readContactMemberNamesAsync(
usernames: string[]
): Promise<Map<string, Wcdb4ContactMemberNames>> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!this.wcdbExecQuery || usernames.length === 0) return result
const inList = this.uniq(usernames)
.map((username) => `'${username.replace(/'/g, "''")}'`)
.join(',')
if (!inList) return result
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'contact',
'',
`SELECT username, nick_name, remark FROM contact WHERE username IN (${inList})`
)
return this.contactMemberNamesFromRows(rows)
} catch {
return result
}
}
/**
* 将 contact 查询行转换为按 wxid 索引的名称映射。
*/
private contactMemberNamesFromRows(
rows: Record<string, unknown>[]
): Map<string, Wcdb4ContactMemberNames> {
const result = new Map<string, Wcdb4ContactMemberNames>()
if (!Array.isArray(rows)) return result
for (const row of rows) {
const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName'])
if (!username) continue
result.set(username, {
wechatNickname: this.pickString(row, ['nick_name', 'nickName', 'm_nsNickName']),
remark: this.pickString(row, ['remark', 'remark_name', 'remarkName'])
})
}
return result
}
private contactIdentitiesFromRows(rows: Record<string, unknown>[]): Wcdb4ContactIdentity[] {
if (!Array.isArray(rows)) return []
return rows.flatMap((row) => {
const username = this.pickString(row, ['username', 'user_name', 'userName', 'm_nsUsrName'])
if (!username) return []
const alias = this.pickString(row, ['alias', 'aliasName', 'alias_name', 'm_nsAlias'])
const wechatId = this.pickString(row, [
'wechatId',
'wechat_id',
'wechatID',
'wxAccount',
'wx_account',
'account'
])
const wxid = this.pickString(row, ['wxid', 'wx_id', 'internalWxid']) || username
const legacyIdentifier = this.pickString(row, [
'legacyIdentifier',
'legacy_identifier',
'originalIdentifier',
'original_identifier',
'originalUsername',
'original_username',
'initialIdentifier',
'initial_identifier'
])
return [
{
username,
wechatId: wechatId || alias || undefined,
wxid,
legacyIdentifier: legacyIdentifier || undefined
}
]
})
}
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, '""')}"`
}
}