From 474250c6c73516d5ce5413a012af663246d108b2 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Fri, 24 Jul 2026 11:27:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=98=B2=E6=92=A4=E5=9B=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 34 ++ src/main/message-parser.ts | 67 ++- src/main/services/chat-service.ts | 27 +- src/main/services/recall-archive-service.ts | 469 ++++++++++++++++++ src/main/wcdb4-client.ts | 270 ++++++++-- src/renderer/src/assets/main.css | 9 + .../src/components/chat/MessageBubble.tsx | 5 + src/shared/types.ts | 18 +- 8 files changed, 850 insertions(+), 49 deletions(-) create mode 100644 src/main/services/recall-archive-service.ts diff --git a/src/main/index.ts b/src/main/index.ts index 9319c43..aa7fad0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -71,6 +71,7 @@ import { installSafeConsole } from './safe-log' import { agentHubService } from './services/agent-hub-service' import { appLogger } from './app-logger' import type { AppLogEntry } from '../shared/app-log' +import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service' // electron-vite can close the child's stdout/stderr after spawning Electron. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC @@ -86,6 +87,7 @@ const aiProviderService = new AIProviderService() const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() let tray: Tray | null = null +let recallArchiveMonitor: RecallArchiveMonitor | null = null const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png') const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon @@ -220,9 +222,41 @@ app.whenReady().then(async () => { } chat.setChatDb(nextWechatDb) const wcdb4Client = nextWechatDb.getWcdb4Client() + configureRecallArchive(resolvedRoot) + recallArchiveMonitor?.stop() + recallArchiveMonitor = new RecallArchiveMonitor( + () => + wcdb4Client.getSessions().map((session) => ({ + md5: wcdb4Client.md5(session.username), + m_nsUsrName: session.username, + type: session.username.endsWith('@chatroom') ? 'group' : 'user', + activityKey: [ + session.raw['last_timestamp'], + session.raw['sort_timestamp'], + session.raw['last_msg_locald_id'], + session.raw['last_msg_type'], + session.raw['summary'] + ].join(':') + })), + (sessionMd5) => + chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, { + limit: 500 + }) + ) + recallArchiveMonitor.seedAll() + setTimeout(() => { + const result = wcdb4Client.installRecallJournal( + wcdb4Client.getSessions().map((session) => session.username) + ) + console.log( + `[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}` + ) + }, 0) voiceService = new VoiceService(wcdb4Client) stickerService = new StickerService(wcdb4Client) const monitoring = wcdb4Client.startMonitor((type, json) => { + wcdb4Client.invalidateSessionCache() + recallArchiveMonitor?.handleDatabaseChange(json) for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) } diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index fe0ce9f..f8dfd56 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -41,7 +41,19 @@ type QuoteContent = { quotedSender?: string quotedType?: string } -type SystemContent = { type: 'system'; content: string; raw?: string } +type SystemContent = { + type: 'system' + content: string + raw?: string + recall?: { + targetId?: string + targetIds?: string[] + replacement: string + actor?: string + sessionId?: string + recallTime?: number + } +} type UnknownContent = { type: 'unknown'; raw: string } export type ParsedContent = @@ -88,6 +100,15 @@ export function parseMessageContent(content: string, messageType: number): Parse function parseSystemMessage(content: string): ParsedContent { const stripped = stripChatroomPrefix(content) const decoded = decodeXmlEntities(stripped) + const recall = extractRecallMessage(decoded) + if (recall) { + return { + type: 'system', + content: recall.replacement, + raw: content, + recall + } + } const delChatroomMemberText = extractDelChatroomMemberText(decoded) if (delChatroomMemberText) { return { @@ -113,6 +134,50 @@ function parseSystemMessage(content: string): ParsedContent { } } +function extractRecallMessage(xml: string): + | { + targetId?: string + targetIds?: string[] + replacement: string + actor?: string + sessionId?: string + recallTime?: number + } + | undefined { + if (!/ let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || '' diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index f1e493f..3e3b00f 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -8,6 +8,7 @@ import type { DatabaseKeyValidationCode, DatabaseKeyValidationResult } from '../../shared/database-key' +import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' export function getCurrentKey(): string { if (!dbRef) return '' @@ -49,8 +50,11 @@ export interface FormattedMessage { voiceDataUrl?: string voiceDuration?: number localId?: number + serverId?: string createTime?: number sessionId?: string + recalled?: boolean + recalledBy?: string } export interface GroupSnapshot { @@ -163,7 +167,7 @@ export function getContactAvatars(usernames: string[]): Record { return dbRef.getWcdb4Client().getAvatarUrls(normalized) } -export function listMessages( +function listSourceMessages( userMd5: string, startTime?: number, endTime?: number, @@ -264,8 +268,12 @@ export function listMessages( if (msgType === 34) content = '[语音消息]' + const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered']) + return { - id: msg.mesLocalID || Math.random().toString(), + id: recoveredFromRecallJournal + ? `recovered:${msg.mesLocalID || msg.serverId || createTime}` + : msg.mesLocalID || Math.random().toString(), from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user', isSender: isMine, type: displayType, @@ -276,7 +284,9 @@ export function listMessages( senderId, sessionId: username, localId, + serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined, createTime, + recoveredFromRecallJournal, contentData } }) @@ -287,6 +297,19 @@ export function listMessages( return formatted } +export function listMessages( + userMd5: string, + startTime?: number, + endTime?: number, + options?: { limit?: number } +): FormattedMessage[] { + const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options) + if (!dbRef) return sourceMessages + const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' + recordRecallArchiveMessages(userMd5, username, sourceMessages) + return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) +} + export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { if (!dbRef) return null const wcdb4Client = dbRef.getWcdb4Client() diff --git a/src/main/services/recall-archive-service.ts b/src/main/services/recall-archive-service.ts new file mode 100644 index 0000000..d313e61 --- /dev/null +++ b/src/main/services/recall-archive-service.ts @@ -0,0 +1,469 @@ +import { app } from 'electron' +import crypto from 'crypto' +import fs from 'fs-extra' +import path from 'path' +import type { Message } from '../../shared/types' + +type RecallRecord = { + targetIds: string[] + actor?: string + noticeId: string + createTime: number +} + +type ArchivedMessage = Message & { + archivedAt: number + recallNotice?: boolean +} + +type SessionArchive = { + username: string + updatedAt: number + messages: ArchivedMessage[] + recalls: RecallRecord[] +} + +type RecallArchiveFile = { + version: 1 + accountRoot: string + updatedAt: number + sessions: Record +} + +const ARCHIVE_VERSION = 1 +const MAX_MESSAGES_PER_SESSION = 3000 +const MAX_RECALLS_PER_SESSION = 500 +const WRITE_DEBOUNCE_MS = 250 + +let archive: RecallArchiveFile | null = null +let archivePath = '' +let writeTimer: NodeJS.Timeout | null = null +let writeQueue: Promise = Promise.resolve() + +function messageIdentity(message: Message): string { + if (message.recoveredFromRecallJournal) { + return `recovered:${message.localId || 0}:${message.serverId || message.id}` + } + if (message.localId) return `local:${message.localId}` + if (message.serverId) return `server:${message.serverId}` + if (message.id) return `id:${message.id}` + return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` +} + +function messageTargetIds(message: Message): string[] { + return Array.from( + new Set( + [ + message.serverId, + message.localId ? String(message.localId) : '', + message.id ? String(message.id) : '' + ].filter((value): value is string => Boolean(value)) + ) + ) +} + +function isRecallNotice(message: Message): boolean { + return message.contentData?.type === 'system' && Boolean(message.contentData.recall) +} + +function matchesRecall(message: Message, recall: RecallRecord): boolean { + const messageIds = messageTargetIds(message) + return recall.targetIds.some((targetId) => messageIds.includes(targetId)) +} + +function inferRecallTarget( + messages: Iterable, + notice: Message, + actor?: string, + claimedTargetIds: Set = new Set() +): ArchivedMessage | undefined { + const noticeTime = notice.createTime || 0 + const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content) + const eligible = Array.from(messages) + .filter((message) => { + if (message.type === '系统消息' || message.from === 'system' || message.recallNotice) + return false + if (!message.recoveredFromRecallJournal) return false + if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false + if (noticeTime && message.createTime && message.createTime > noticeTime) return false + if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false + return true + }) + .sort((left, right) => { + const timeDelta = (right.createTime || 0) - (left.createTime || 0) + if (timeDelta) return timeDelta + return (right.localId || 0) - (left.localId || 0) + }) + + const strict = eligible.find((message) => { + if (expectsMine) return message.isSender + if (!actor) return true + const names = [message.name, message.senderId].map((value) => String(value || '').trim()) + return !names.some(Boolean) || names.includes(actor) + }) + + // Journal rows are captured at deletion time, so the fallback never points at + // an unrelated visible message merely because it happened to be nearby. + return strict || eligible[0] +} + +function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message { + if (isRecallNotice(message)) return message + const recall = recalls.find((item) => matchesRecall(message, item)) + if (!recall) return message + const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim()) + return { + ...message, + from: recalledByMe ? 'assistant' : message.from, + isSender: recalledByMe ? true : message.isSender, + recalled: true, + recalledBy: recall.actor + } +} + +function scheduleWrite(): void { + if (!archive || !archivePath) return + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + if (!archive || !archivePath) return + const targetPath = archivePath + const serialized = JSON.stringify(archive) + writeQueue = writeQueue + .catch(() => undefined) + .then(async () => { + await fs.ensureDir(path.dirname(targetPath)) + const temporaryPath = `${targetPath}.tmp` + await fs.writeFile(temporaryPath, serialized, 'utf8') + await fs.move(temporaryPath, targetPath, { overwrite: true }) + }) + .catch((error) => { + console.warn('[RecallArchive] write failed:', error) + }) + }, WRITE_DEBOUNCE_MS) +} + +function loadArchive(accountRoot: string): RecallArchiveFile { + const hash = crypto + .createHash('sha1') + .update(`${process.platform}:${accountRoot}`) + .digest('hex') + .slice(0, 16) + archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`) + + try { + if (fs.existsSync(archivePath)) { + const stored = fs.readJsonSync(archivePath) as Partial + if ( + stored.version === ARCHIVE_VERSION && + stored.accountRoot === accountRoot && + stored.sessions && + typeof stored.sessions === 'object' + ) { + return stored as RecallArchiveFile + } + } + } catch (error) { + console.warn('[RecallArchive] read failed:', error) + } + + return { + version: ARCHIVE_VERSION, + accountRoot, + updatedAt: Date.now(), + sessions: {} + } +} + +export function configureRecallArchive(accountRoot: string): void { + const normalizedRoot = String(accountRoot || '').trim() + if (!normalizedRoot) { + archive = null + archivePath = '' + return + } + if (archive?.accountRoot === normalizedRoot) return + archive = loadArchive(normalizedRoot) +} + +export function recordRecallArchiveMessages( + sessionMd5: string, + username: string, + messages: Message[] +): void { + if (!archive || !sessionMd5 || messages.length === 0) return + + const bucket = + archive.sessions[sessionMd5] || + ({ + username, + updatedAt: Date.now(), + messages: [], + recalls: [] + } satisfies SessionArchive) + if (username) bucket.username = username + + const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message])) + let changed = false + const targetlessNoticeIds = new Set( + messages + .filter((message) => { + const recall = + message.contentData?.type === 'system' ? message.contentData.recall : undefined + return Boolean(recall && !(recall.targetIds?.length || recall.targetId)) + }) + .map(messageIdentity) + ) + if (targetlessNoticeIds.size > 0) { + const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId)) + if (retained.length !== bucket.recalls.length) { + bucket.recalls = retained + changed = true + } + } + + for (const sourceMessage of messages) { + const recallData = + sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined + if (recallData) { + let targetIds = Array.from( + new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean)) + ) + const noticeId = messageIdentity(sourceMessage) + const noticeTargetIds = new Set(messageTargetIds(sourceMessage)) + const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId) + const existingRecord = + existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined + const existingTargetsNotice = + existingRecord && + existingRecord.targetIds.length > 0 && + existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId)) + + if ( + targetIds.length === 0 && + existingRecord && + !existingTargetsNotice && + !targetlessNoticeIds.has(noticeId) + ) { + targetIds = existingRecord.targetIds + } + if (targetIds.length === 0) { + const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds)) + const inferred = inferRecallTarget( + byIdentity.values(), + sourceMessage, + recallData.actor, + claimedTargetIds + ) + if (inferred) targetIds = messageTargetIds(inferred) + } + if (targetIds.length > 0) { + const nextRecord: RecallRecord = { + targetIds, + actor: recallData.actor, + noticeId, + createTime: sourceMessage.createTime || 0 + } + const unchanged = + existingRecord && + existingRecord.targetIds.length === targetIds.length && + existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) && + existingRecord.actor === nextRecord.actor + if (!unchanged) { + if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord) + else bucket.recalls.push(nextRecord) + changed = true + } + } + } + + const annotated = annotateFromRecall(sourceMessage, bucket.recalls) + const next: ArchivedMessage = { + ...annotated, + archivedAt: Date.now(), + recallNotice: isRecallNotice(sourceMessage) + } + const identity = messageIdentity(next) + const previous = byIdentity.get(identity) + if ( + !previous || + previous.recalled !== next.recalled || + previous.content !== next.content || + previous.serverId !== next.serverId + ) { + byIdentity.set(identity, next) + changed = true + } + } + + const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION) + const nextMessages = Array.from(byIdentity.values()) + .map((message) => annotateFromRecall(message, recalls) as ArchivedMessage) + .sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right)) + }) + .slice(-MAX_MESSAGES_PER_SESSION) + + if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return + + bucket.messages = nextMessages + bucket.recalls = recalls + bucket.updatedAt = Date.now() + archive.sessions[sessionMd5] = bucket + archive.updatedAt = Date.now() + scheduleWrite() +} + +export function mergeRecallArchiveMessages( + sessionMd5: string, + sourceMessages: T[], + startTime?: number, + endTime?: number, + limit?: number +): T[] { + const bucket = archive?.sessions[sessionMd5] + if (!bucket) return sourceMessages + + const merged = new Map() + for (const message of sourceMessages) { + const archived = bucket.messages.find( + (candidate) => messageIdentity(candidate) === messageIdentity(message) + ) + merged.set( + messageIdentity(message), + annotateFromRecall( + archived?.recalled + ? { ...message, recalled: true, recalledBy: archived.recalledBy } + : message, + bucket.recalls + ) + ) + } + + for (const archived of bucket.messages) { + if (!archived.recalled && !archived.recallNotice) continue + const createTime = archived.createTime || 0 + if (startTime && createTime < startTime) continue + if (endTime && createTime > endTime) continue + const identity = messageIdentity(archived) + if (!merged.has(identity)) merged.set(identity, archived) + } + + const result = Array.from(merged.values()).sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right)) + }) + const visible = limit && result.length > limit ? result.slice(-limit) : result + return visible as T[] +} + +export function extractChangedConversationMd5(payload: string): string[] { + const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || [] + return Array.from( + new Set( + matches + .map((value) => value.replace(/^chat_/i, '').toLowerCase()) + .filter((value) => /^[a-f0-9]{32}$/.test(value)) + ) + ) +} + +type ArchiveContact = { + md5: string + m_nsUsrName: string + type: 'user' | 'group' + activityKey?: string +} + +export class RecallArchiveMonitor { + private pending = new Set() + private activityBySession = new Map() + private timer: NodeJS.Timeout | null = null + private stopped = false + + constructor( + private readonly listContacts: () => ArchiveContact[], + private readonly loadMessages: (sessionMd5: string) => Message[] + ) {} + + seedAll(): void { + const groups = this.conversationContacts() + this.activityBySession = new Map( + groups.map((contact) => [contact.md5, String(contact.activityKey || '')]) + ) + const allowed = new Set(groups.map((contact) => contact.md5)) + if (archive) { + let pruned = false + for (const sessionMd5 of Object.keys(archive.sessions)) { + if (allowed.has(sessionMd5)) continue + delete archive.sessions[sessionMd5] + pruned = true + } + if (pruned) { + archive.updatedAt = Date.now() + scheduleWrite() + } + } + } + + handleDatabaseChange(payload: string): void { + const groups = this.conversationContacts() + const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase())) + const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5)) + const changedActivity = groups + .filter((contact) => { + const nextKey = String(contact.activityKey || '') + const previousKey = this.activityBySession.get(contact.md5) + this.activityBySession.set(contact.md5, nextKey) + return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey) + }) + .map((contact) => contact.md5) + const targets = Array.from(new Set([...payloadTargets, ...changedActivity])) + if (targets.length > 0) { + console.log(`[RecallArchive] database change queued=${targets.length}`) + this.enqueue(targets) + } + } + + private conversationContacts(): ArchiveContact[] { + return this.listContacts().filter( + (contact) => + contact.m_nsUsrName && + !contact.m_nsUsrName.startsWith('@placeholder') && + contact.m_nsUsrName !== 'brandsessionholder' && + contact.m_nsUsrName !== 'brandservicesessionholder' + ) + } + + stop(): void { + this.stopped = true + this.pending.clear() + if (this.timer) clearTimeout(this.timer) + this.timer = null + } + + private enqueue(sessionMd5s: string[]): void { + if (this.stopped) return + for (const md5 of sessionMd5s) { + if (md5) this.pending.add(md5) + } + this.schedule() + } + + private schedule(): void { + if (this.stopped || this.timer || this.pending.size === 0) return + this.timer = setTimeout(() => { + this.timer = null + const md5 = this.pending.values().next().value as string | undefined + if (!md5) return + this.pending.delete(md5) + try { + this.loadMessages(md5) + } catch (error) { + console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error) + } + this.schedule() + }, 20) + } +} diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 876a3ce..a54e2b2 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -16,6 +16,7 @@ export interface Wcdb4Session { export interface Wcdb4Message { mesLocalID: string + serverId?: string mesDes: number messageType: string msgCreateTime: string @@ -30,6 +31,11 @@ export interface Wcdb4MessageQueryOptions { limit?: number } +type Wcdb4MessageStore = { + tableName: string + dbPath: string +} + export interface Wcdb4GroupMember { m_nsUsrName: string nickname: string @@ -307,7 +313,9 @@ export class Wcdb4Client { candidates.push(...discoverWindowsDbRoots()) return Array.from(new Set(candidates)) } - return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')] + return [ + path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files') + ] } private static getWeflowDbPathCandidates(home: string): string[] { @@ -331,7 +339,9 @@ export class Wcdb4Client { private static hasDbStorage(candidate: string): boolean { try { - return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage')) + return ( + fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage')) + ) } catch { return false } @@ -588,6 +598,11 @@ export class Wcdb4Client { return this.cachedSessions } + invalidateSessionCache(): void { + this.cachedSessions = null + this.cachedChatTables = null + } + getChatTables(): { name: string; db_number: string }[] { if (this.cachedChatTables) return this.cachedChatTables const sessions = @@ -616,10 +631,12 @@ export class Wcdb4Client { try { const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows) if (cursorMessages) { + const recoveredMessages = this.readRecallJournal(username, startTime, endTime) + const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows) console.log( - `[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms` + `[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms` ) - return cursorMessages + return mergedMessages } } catch (error) { console.warn(`[WCDB4] cursor messages failed username=${username}:`, error) @@ -648,10 +665,7 @@ export class Wcdb4Client { this.wcdbGetMessages!(handle, username, limit, offset, outJson) ) } catch (error) { - console.warn( - `[WCDB4] get_messages failed username=${username} offset=${offset}:`, - error - ) + console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error) break } const batch = Array.isArray(rows) ? rows : [] @@ -694,17 +708,9 @@ export class Wcdb4Client { ): Wcdb4Message[] { if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] - let tables: { tableName: string; dbPath: string }[] = [] + let tables: Wcdb4MessageStore[] = [] try { - const rows = this.callJson[]>((handle, outJson) => - this.wcdbGetMessageTableStats!(handle, username, outJson) - ) - tables = (Array.isArray(rows) ? rows : []) - .map((row) => ({ - tableName: this.pickString(row, ['table_name', 'tableName', 'name']), - dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) - })) - .filter((row) => row.tableName && row.dbPath) + tables = this.listMessageStores(username) } catch (error) { console.warn(`[WCDB4] message table stats failed username=${username}:`, error) return [] @@ -739,6 +745,172 @@ export class Wcdb4Client { return this.finalizeMessages(username, allRows, startTime, endTime, limit) } + installRecallJournal(usernames: string[]): { installed: number; failed: number } { + const stores = new Map() + for (const username of this.uniq(usernames)) { + try { + for (const store of this.listMessageStores(username)) { + stores.set(`${store.dbPath}\u0000${store.tableName}`, store) + } + } catch (error) { + console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error) + } + } + + let installed = 0 + let failed = 0 + for (const store of stores.values()) { + try { + const columns = this.readMessageColumns(store) + if (columns.length === 0) throw new Error('消息表没有可归档列') + this.ensureRecallJournalTable(store, columns) + this.createRecallJournalTrigger(store, columns) + installed += 1 + } catch (error) { + failed += 1 + console.warn( + `[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`, + error + ) + } + } + return { installed, failed } + } + + private listMessageStores(username: string): Wcdb4MessageStore[] { + if (!this.wcdbGetMessageTableStats) return [] + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetMessageTableStats!(handle, username, outJson) + ) + return (Array.isArray(rows) ? rows : []) + .map((row) => ({ + tableName: this.pickString(row, ['table_name', 'tableName', 'name']), + dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) + })) + .filter((row) => row.tableName && row.dbPath) + } + + private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record[] { + if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') + const rows = this.callJson[]>((handle, outJson) => + this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson) + ) + return Array.isArray(rows) ? rows : [] + } + + private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] { + const rows = this.executeMessageSql( + store, + `PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})` + ) + return rows + .map((row) => { + const name = this.pickString(row, ['name']) + const type = this.pickString(row, ['type']).toUpperCase() + const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB' + return { name, declaration } + }) + .filter((column) => column.name) + } + + private ensureRecallJournalTable( + store: Wcdb4MessageStore, + columns: { name: string; declaration: string }[] + ): void { + const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) + const definitions = columns + .map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`) + .join(', ') + this.executeMessageSql( + store, + `CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)` + ) + } + + private createRecallJournalTrigger( + store: Wcdb4MessageStore, + columns: { name: string; declaration: string }[] + ): void { + const table = this.quoteSqlIdentifier(store.tableName) + const triggerName = this.quoteSqlIdentifier( + `_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}` + ) + const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) + const targetColumns = [ + ...columns.map((column) => this.quoteSqlIdentifier(column.name)), + '"_wxe_source_table"', + '"_wxe_captured_at"' + ].join(', ') + const sourceValues = [ + ...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`), + `'${store.tableName.replace(/'/g, "''")}'`, + `CAST(strftime('%s', 'now') AS INTEGER)` + ].join(', ') + this.executeMessageSql( + store, + `CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END` + ) + } + + private readRecallJournal( + username: string, + startTime?: number, + endTime?: number + ): Wcdb4Message[] { + const recoveredRows: Record[] = [] + for (const store of this.listMessageStores(username)) { + try { + const begin = this.normalizeTimestamp(startTime || 0) + const end = this.normalizeTimestamp(endTime || 0) + const where = [ + `"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`, + begin > 0 ? `"create_time" >= ${begin}` : '', + end > 0 ? `"create_time" <= ${end}` : '' + ].filter(Boolean) + const rows = this.executeMessageSql( + store, + `SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500` + ) + recoveredRows.push(...rows) + } catch { + // The journal is optional until installation has completed for this store. + } + } + return this.finalizeMessages(username, recoveredRows, startTime, endTime) + } + + private mergeMessageRows( + current: Wcdb4Message[], + recovered: Wcdb4Message[], + limit?: number + ): Wcdb4Message[] { + const merged = new Map() + for (const message of [...recovered, ...current]) { + const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) + const identity = message.mesLocalID + ? `local:${message.mesLocalID}` + : message.serverId + ? `server:${message.serverId}` + : `${message.msgCreateTime}:${message.msgContent}` + const key = recoveredRow ? `recovered:${identity}` : identity + merged.set(key, message) + } + const messages = Array.from(merged.values()).sort( + (left, right) => + Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) || + Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0) + ) + return limit && messages.length > limit ? messages.slice(-limit) : messages + } + + private recallJournalTableName(messageTableName: string): string { + return `_wxe_recall_journal_${crypto + .createHash('sha1') + .update(messageTableName) + .digest('hex') + .slice(0, 16)}` + } + getMyAvatarUrl(): string | undefined { const candidates = this.getMyUsernameCandidates() this.hydrateAvatarUrls(candidates) @@ -856,26 +1028,25 @@ export class Wcdb4Client { const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted - return visibleMessages - .map((message) => { - if (!message.sender) return message - const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname - const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar - const shouldPrefixSender = - username.endsWith('@chatroom') && - message.mesDes === 1 && - message.sender && - message.msgContent && - !message.msgContent.startsWith(`${message.sender}:`) - return { - ...message, - senderNickname, - senderAvatar, - msgContent: shouldPrefixSender - ? `${message.sender}:\n${message.msgContent}` - : message.msgContent - } - }) + return visibleMessages.map((message) => { + if (!message.sender) return message + const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname + const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar + const shouldPrefixSender = + username.endsWith('@chatroom') && + message.mesDes === 1 && + message.sender && + message.msgContent && + !message.msgContent.startsWith(`${message.sender}:`) + return { + ...message, + senderNickname, + senderAvatar, + msgContent: shouldPrefixSender + ? `${message.sender}:\n${message.msgContent}` + : message.msgContent + } + }) } private normalizeMessageLimit(limit?: number): number | undefined { @@ -915,11 +1086,7 @@ export class Wcdb4Client { 'contactRemark', 'contact_remark' ]) - const memberNickname = this.pickString(row, [ - 'displayName', - 'display_name', - 'name' - ]) + const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name']) const avatar = this.pickString(row, [ 'avatarUrl', 'avatar_url', @@ -933,8 +1100,7 @@ export class Wcdb4Client { return { m_nsUsrName: username, - nickname: - groupNicknames.get(username) || remark || wechatNickname || memberNickname, + nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname, groupNickname: groupNicknames.get(username) || '', wechatNickname: wechatNickname || memberNickname, remark, @@ -1520,6 +1686,19 @@ export class Wcdb4Client { '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', @@ -1541,6 +1720,7 @@ export class Wcdb4Client { return { mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`, + serverId: serverId || undefined, mesDes: isSend ? 0 : 1, messageType: messageType || '1', msgCreateTime: String(createTime), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 393d89f..18ca3e7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1296,6 +1296,15 @@ body { font-size: 12px; } +.message-recalled-status { + align-self: flex-end; + margin-top: 3px; + color: #c63c32; + font-size: 11px; + font-weight: 600; + line-height: 16px; +} + .message-hover-time { position: absolute; top: 50%; diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 9d5df9c..30d6dab 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -56,6 +56,11 @@ export function MessageBubble({
{renderWechatEmojiText(message.content)}
)} + {message.recalled && ( + + 消息已撤回 + + )} {hoverTime} {!isGroupChat && !isMine && contact.m_nsNickName && ( {contact.m_nsNickName} diff --git a/src/shared/types.ts b/src/shared/types.ts index 2bf8c61..0c6ab26 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,8 +20,12 @@ export interface Message { voiceDataUrl?: string voiceDuration?: number localId?: number + serverId?: string createTime?: number sessionId?: string + recalled?: boolean + recalledBy?: string + recoveredFromRecallJournal?: boolean } type TextContent = { type: 'text'; content: string } @@ -67,7 +71,19 @@ type QuoteContent = { quotedSender?: string quotedType?: string } -type SystemContent = { type: 'system'; content: string; raw?: string } +type SystemContent = { + type: 'system' + content: string + raw?: string + recall?: { + targetId?: string + targetIds?: string[] + replacement: string + actor?: string + sessionId?: string + recallTime?: number + } +} type UnknownContent = { type: 'unknown'; raw: string } export type ParsedContent =