修复 #597;实现 #556;修复 #623与 #543;修复卡片图片问题

This commit is contained in:
cc
2026-04-04 23:14:54 +08:00
parent 05fdbab496
commit a0189fdd0a
17 changed files with 1657 additions and 286 deletions

View File

@@ -1867,6 +1867,18 @@ function registerIpcHandlers() {
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
})
ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.checkAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.installAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => {
return chatService.uninstallAntiRevokeTriggers(sessionIds)
})
ipcMain.handle('chat:getContact', async (_, username: string) => {
return await chatService.getContact(username)
})
@@ -2299,10 +2311,47 @@ function registerIpcHandlers() {
})
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', progress)
const PROGRESS_FORWARD_INTERVAL_MS = 180
let pendingProgress: ExportProgress | null = null
let progressTimer: NodeJS.Timeout | null = null
let lastProgressSentAt = 0
const flushProgress = () => {
if (!pendingProgress) return
if (progressTimer) {
clearTimeout(progressTimer)
progressTimer = null
}
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', pendingProgress)
}
pendingProgress = null
lastProgressSentAt = Date.now()
}
const queueProgress = (progress: ExportProgress) => {
pendingProgress = progress
const force = progress.phase === 'complete'
if (force) {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressSentAt
if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) {
flushProgress()
return
}
if (progressTimer) return
progressTimer = setTimeout(() => {
flushProgress()
}, PROGRESS_FORWARD_INTERVAL_MS - elapsed)
}
const onProgress = (progress: ExportProgress) => {
queueProgress(progress)
}
const runMainFallback = async (reason: string) => {
@@ -2381,6 +2430,12 @@ function registerIpcHandlers() {
return await runWorker()
} catch (error) {
return runMainFallback(error instanceof Error ? error.message : String(error))
} finally {
flushProgress()
if (progressTimer) {
clearTimeout(progressTimer)
progressTimer = null
}
}
})

View File

@@ -190,6 +190,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
checkAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
installAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename, extname } from 'path'
import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
import * as path from 'path'
import * as fs from 'fs'
@@ -558,6 +558,51 @@ class ChatService {
}
}
async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
async installAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/
@@ -1773,18 +1818,9 @@ class ChatService {
}
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
const dbPath = String(
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|| ''
).trim()
const explicitDbName = String(
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|| ''
).trim()
const tableName = String(
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|| ''
).trim()
const dbPath = String(row._db_path || row.db_path || '').trim()
const explicitDbName = String(row.db_name || '').trim()
const tableName = String(row.table_name || '').trim()
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
return {
dbName: dbName || undefined,
@@ -3201,7 +3237,7 @@ class ChatService {
if (!batch.success) break
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
for (const row of rows) {
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const localType = this.getRowInt(row, ['local_type'], 1)
if (localType === 50) {
counters.callMessages += 1
continue
@@ -3216,8 +3252,8 @@ class ChatService {
}
if (localType !== 49) continue
const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
const rawMessageContent = row.message_content
const rawCompressContent = row.compress_content
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const xmlType = this.extractType49XmlTypeForStats(content)
if (xmlType === '2000') counters.transferMessages += 1
@@ -3270,7 +3306,7 @@ class ChatService {
for (const row of rows) {
stats.totalMessages += 1
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const localType = this.getRowInt(row, ['local_type'], 1)
if (localType === 34) stats.voiceMessages += 1
if (localType === 3) stats.imageMessages += 1
if (localType === 43) stats.videoMessages += 1
@@ -3279,8 +3315,8 @@ class ChatService {
if (localType === 8589934592049) stats.transferMessages += 1
if (localType === 8594229559345) stats.redPacketMessages += 1
if (localType === 49) {
const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
const rawMessageContent = row.message_content
const rawCompressContent = row.compress_content
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const xmlType = this.extractType49XmlTypeForStats(content)
if (xmlType === '2000') stats.transferMessages += 1
@@ -3289,7 +3325,7 @@ class ChatService {
const createTime = this.getRowInt(
row,
['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'],
['create_time'],
0
)
if (createTime > 0) {
@@ -3302,7 +3338,7 @@ class ChatService {
}
if (sessionId.endsWith('@chatroom')) {
const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim()
const sender = String(row.sender_username || '').trim()
const senderKeys = this.buildIdentityKeys(sender)
if (senderKeys.length > 0) {
senderIdentities.add(senderKeys[0])
@@ -3310,7 +3346,7 @@ class ChatService {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
}
} else {
const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']))
const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send)
if (Number.isFinite(isSend) && isSend === 1) {
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
}
@@ -3744,32 +3780,18 @@ class ChatService {
const messages: Message[] = []
for (const row of rows) {
const sourceInfo = this.getMessageSourceInfo(row)
const rawMessageContent = this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]);
const rawCompressContent = this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
]);
const rawMessageContent = row.message_content
const rawCompressContent = row.compress_content
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const localType = this.getRowInt(row, ['local_type'], 1)
const isSendRaw = row.computed_is_send ?? row.is_send
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
const senderUsername = row.sender_username
|| this.extractSenderUsernameFromContent(content)
|| null
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
const createTime = this.getRowInt(row, ['create_time'], 0)
if (senderUsername && !myWxid) {
// [DEBUG] Issue #34: 未配置 myWxid无法判断是否发送
@@ -3954,10 +3976,10 @@ class ChatService {
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
}
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
const localId = this.getRowInt(row, ['local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
const serverId = this.getRowInt(row, ['server_id'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
messages.push({
messageKey: this.buildMessageKey({
@@ -4404,18 +4426,7 @@ class ChatService {
}
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = this.getRowField(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info',
'WCDB_CT_PackedInfoData',
'WCDB_CT_PackedInfo'
])
const packed = row.packed_info_data
const buffer = this.decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = []
@@ -5303,14 +5314,14 @@ class ChatService {
row: Record<string, any>,
rawContent: string
): Promise<string | null> {
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
const directSender = row.sender_username
|| this.extractSenderUsernameFromContent(rawContent)
if (directSender) {
return directSender
}
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
const dbPath = row._db_path
const realSenderId = row.real_sender_id
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
return null
}
@@ -5359,7 +5370,7 @@ class ChatService {
50: '[通话]',
10000: '[系统消息]',
244813135921: '[引用消息]',
266287972401: '[拍一拍]',
266287972401: '拍一拍',
81604378673: '[聊天记录]',
154618822705: '[小程序]',
8594229559345: '[红包]',
@@ -5468,7 +5479,7 @@ class ChatService {
* XML: <msg><appmsg...><title>"XX"拍了拍"XX"相信未来!</title>...</msg>
*/
private cleanPatMessage(content: string): string {
if (!content) return '[拍一拍]'
if (!content) return '拍一拍'
// 1. 优先从 XML <title> 标签提取内容
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
@@ -5478,14 +5489,14 @@ class ChatService {
.replace(/\]\]>/g, '')
.trim()
if (title) {
return `[拍一拍] ${title}`
return title
}
}
// 2. 尝试匹配标准的 "A拍了拍B" 格式
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
if (match) {
return `[拍一拍] ${match[1].trim()}`
return match[1].trim()
}
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
@@ -5499,10 +5510,10 @@ class ChatService {
// 如果清理后还有内容,返回
if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) {
return `[拍一拍] ${cleaned}`
return cleaned
}
return '[拍一拍]'
return '拍一拍'
}
/**
@@ -7520,11 +7531,7 @@ class ChatService {
for (const row of result.messages) {
let message = await this.parseMessage(row, { source: 'search', sessionId })
const resolvedSessionId = String(
sessionId ||
this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username'])
|| ''
).trim()
const resolvedSessionId = String(sessionId || row._session_id || '').trim()
const needsDetailHydration = isGroupSearch &&
Boolean(sessionId) &&
message.localId > 0 &&
@@ -7559,32 +7566,18 @@ class ChatService {
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
const sourceInfo = this.getMessageSourceInfo(row)
const rawContent = this.decodeMessageContent(
this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]),
this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
row.message_content,
row.compress_content
)
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const localId = this.getRowInt(row, ['local_id'], 0)
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
const serverId = this.getRowInt(row, ['server_id'], 0)
const localType = this.getRowInt(row, ['local_type'], 0)
const createTime = this.getRowInt(row, ['create_time'], 0)
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
const rawIsSend = row.computed_is_send ?? row.is_send
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
const msg: Message = {
@@ -7612,8 +7605,8 @@ class ChatService {
}
if (msg.localId === 0 || msg.createTime === 0) {
const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'])
const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'])
const rawLocalId = row.local_id
const rawCreateTime = row.create_time
console.warn('[ChatService] parseMessage raw keys', {
rawLocalId,
rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null',

View File

@@ -61,6 +61,7 @@ interface ConfigSchema {
windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[]
exportWriteLayout: 'A' | 'B' | 'C'
}
// 需要 safeStorage 加密的字段(普通模式)
@@ -133,7 +134,8 @@ export class ConfigService {
messagePushEnabled: false,
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: []
wordCloudExcludeWords: [],
exportWriteLayout: 'A'
}
const storeOptions: any = {

View File

@@ -430,6 +430,8 @@ class ExportService {
let lastSessionId = ''
let lastCollected = 0
let lastExported = 0
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
const commit = (progress: ExportProgress) => {
onProgress(progress)
@@ -454,9 +456,9 @@ class ExportService {
const shouldEmit = force ||
phase !== lastPhase ||
sessionId !== lastSessionId ||
collectedDelta >= 200 ||
exportedDelta >= 200 ||
(now - lastSentAt >= 120)
collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)
if (shouldEmit && pending) {
commit(pending)
@@ -3537,20 +3539,11 @@ class ExportService {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
result.localPath = thumbResult.localPath
} else {
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
const { imageStore } = await import('../main')
const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName)
if (cachedThumb) {
console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`)
result.localPath = cachedThumb
} else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
}
@@ -3559,7 +3552,7 @@ class ExportService {
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
// 从 data URL 或 file URL 获取实际路径
let sourcePath = result.localPath
let sourcePath: string = result.localPath!
if (sourcePath.startsWith('data:')) {
// 是 data URL需要保存为文件
const base64Data = sourcePath.split(',')[1]
@@ -8389,22 +8382,22 @@ class ExportService {
const metric = aggregatedData?.[sessionId]
const totalCount = Number.isFinite(metric?.totalMessages)
? Math.max(0, Math.floor(metric!.totalMessages))
? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
: 0
const voiceCount = Number.isFinite(metric?.voiceMessages)
? Math.max(0, Math.floor(metric!.voiceMessages))
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
: 0
const imageCount = Number.isFinite(metric?.imageMessages)
? Math.max(0, Math.floor(metric!.imageMessages))
? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
: 0
const videoCount = Number.isFinite(metric?.videoMessages)
? Math.max(0, Math.floor(metric!.videoMessages))
? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
: 0
const emojiCount = Number.isFinite(metric?.emojiMessages)
? Math.max(0, Math.floor(metric!.emojiMessages))
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
: 0
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
? Math.max(0, Math.floor(metric!.lastTimestamp))
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
: undefined
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
const sessionCachedVoiceCount = Math.min(

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename } from 'path'
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
@@ -92,6 +92,9 @@ export class WcdbCore {
private wcdbResolveImageHardlinkBatch: any = null
private wcdbResolveVideoHardlinkMd5: any = null
private wcdbResolveVideoHardlinkMd5Batch: any = null
private wcdbInstallMessageAntiRevokeTrigger: any = null
private wcdbUninstallMessageAntiRevokeTrigger: any = null
private wcdbCheckMessageAntiRevokeTrigger: any = null
private wcdbInstallSnsBlockDeleteTrigger: any = null
private wcdbUninstallSnsBlockDeleteTrigger: any = null
private wcdbCheckSnsBlockDeleteTrigger: any = null
@@ -163,7 +166,7 @@ export class WcdbCore {
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
this.wcdbFreeString(namePtr[0])
}
} catch {}
} catch { }
}
this.connectMonitorPipe(pipePath)
return true
@@ -181,7 +184,7 @@ export class WcdbCore {
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { })
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
@@ -273,7 +276,7 @@ export class WcdbCore {
const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) {
return envDllPath
@@ -313,7 +316,7 @@ export class WcdbCore {
'-2302': 'WCDB 初始化异常,请重试',
'-2303': 'WCDB 未能成功初始化',
}
const msg = messages[String(code) as keyof typeof messages]
const msg = messages[String(code) as unknown as keyof typeof messages]
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
}
@@ -643,7 +646,7 @@ export class WcdbCore {
const dllDir = dirname(dllPath)
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
// 预加载依赖库
if (isMac) {
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
@@ -1077,6 +1080,27 @@ export class WcdbCore {
this.wcdbResolveVideoHardlinkMd5Batch = null
}
// wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
try {
this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
} catch {
this.wcdbInstallMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
try {
this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
} catch {
this.wcdbUninstallMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed)
try {
this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)')
} catch {
this.wcdbCheckMessageAntiRevokeTrigger = null
}
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
try {
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
@@ -1337,12 +1361,12 @@ export class WcdbCore {
const raw = String(jsonStr || '')
if (!raw) return []
// 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。
const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw)
const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw)
if (!needsInt64Normalize) {
return JSON.parse(raw)
}
const normalized = raw.replace(
/("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g,
/("server_id"\s*:\s*)(-?\d{16,})/g,
'$1"$2"'
)
return JSON.parse(normalized)
@@ -1655,6 +1679,9 @@ export class WcdbCore {
const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
if (result !== 0) {
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `获取消息总数失败: ${result}` }
}
return { success: true, count: outCount[0] }
@@ -1685,6 +1712,9 @@ export class WcdbCore {
const sessionId = normalizedSessionIds[i]
const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
if (result === -7) {
return { success: false, error: `message schema mismatch会话 ${sessionId} 的消息表结构不匹配` }
}
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
if (i > 0 && i % 160 === 0) {
@@ -1704,6 +1734,9 @@ export class WcdbCore {
const outPtr = [null as any]
const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr)
if (result !== 0 || !outPtr[0]) {
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `获取会话消息总数失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
@@ -2661,7 +2694,9 @@ export class WcdbCore {
)
const hint = result === -3
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
: `创建游标失败: ${result},请查看日志`
: result === -7
? 'message schema mismatch当前账号消息表结构与程序要求不一致'
: `创建游标失败: ${result},请查看日志`
return { success: false, error: hint }
}
return { success: true, cursor: outCursor[0] }
@@ -2719,6 +2754,9 @@ export class WcdbCore {
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}
return { success: false, error: `创建游标失败: ${result},请查看日志` }
}
return { success: true, cursor: outCursor[0] }
@@ -2790,14 +2828,14 @@ export class WcdbCore {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
if (params && params.length > 0) {
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL可能存在注入风险')
}
const normalizedKind = String(kind || '').toLowerCase()
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
let effectivePath = path || ''
@@ -3481,6 +3519,122 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outPtr = [null]
const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status === 1) {
return { success: true, alreadyInstalled: true }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true, alreadyInstalled: false }
} catch (e) {
return { success: false, error: String(e) }
}
}
async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outPtr = [null]
const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
try {
const outInstalled = [0]
const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled)
if (status !== 0) {
return { success: false, error: `DLL error ${status}` }
}
return { success: true, installed: outInstalled[0] === 1 }
} catch (e) {
return { success: false, error: String(e) }
}
}
async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.checkMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error })
}
return { success: true, rows }
}
async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.installMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error })
}
return { success: true, rows }
}
async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
success: boolean
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
error?: string
}> {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
return { success: true, rows: [] }
}
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
const rows: Array<{ sessionId: string; success: boolean; error?: string }> = []
for (const sessionId of uniqueIds) {
const result = await this.uninstallMessageAntiRevokeTrigger(sessionId)
rows.push({ sessionId, success: result.success, error: result.error })
}
return { success: true, rows }
}
/**
* 为朋友圈安装删除
*/

View File

@@ -561,6 +561,24 @@ export class WcdbService {
return this.callWorker('getSnsExportStats', { myWxid })
}
async checkMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
}
async installMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
}
async uninstallMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
}
/**
* 安装朋友圈删除拦截
*/

View File

@@ -230,6 +230,15 @@ if (parentPort) {
case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid)
break
case 'checkMessageAntiRevokeTriggers':
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installMessageAntiRevokeTriggers':
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'uninstallMessageAntiRevokeTriggers':
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger()
break