feat: 添加微信消息防撤回功能

This commit is contained in:
Wxw-Gu
2026-07-24 11:27:44 +08:00
parent d0ceeceb68
commit 474250c6c7
8 changed files with 850 additions and 49 deletions
+34
View File
@@ -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 })
}
+66 -1
View File
@@ -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 (!/<revokemsg\b/i.test(xml)) return undefined
const replacement = normalizeSystemText(
extractXmlValue(xml, 'replacemsg') ||
extractXmlNodeText(xml, 'replacemsg') ||
extractXmlValue(xml, 'content') ||
extractXmlNodeText(xml, 'content')
)
if (!replacement) return undefined
const actorMatch =
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
/^(.+?)\s*撤回了一条消息/.exec(replacement)
const targetIds = Array.from(
new Set(
[
extractXmlValue(xml, 'newmsgid'),
extractXmlValue(xml, 'msgid'),
extractXmlValue(xml, 'clientmsgid')
].filter(Boolean)
)
)
return {
targetId: targetIds[0] || undefined,
targetIds,
replacement,
actor: actorMatch?.[1]?.trim() || undefined,
sessionId: extractXmlValue(xml, 'session') || undefined,
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
}
}
function parseImageMessage(content: string): ParsedContent {
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
+25 -2
View File
@@ -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<string, string> {
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()
+469
View File
@@ -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<string, SessionArchive>
}
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<void> = 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<ArchivedMessage>,
notice: Message,
actor?: string,
claimedTargetIds: Set<string> = 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<RecallArchiveFile>
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<T extends Message>(
sessionMd5: string,
sourceMessages: T[],
startTime?: number,
endTime?: number,
limit?: number
): T[] {
const bucket = archive?.sessions[sessionMd5]
if (!bucket) return sourceMessages
const merged = new Map<string, Message>()
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<string>()
private activityBySession = new Map<string, string>()
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)
}
}
+225 -45
View File
@@ -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<Record<string, unknown>[]>((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<string, Wcdb4MessageStore>()
for (const username of this.uniq(usernames)) {
try {
for (const store of this.listMessageStores(username)) {
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
}
} catch (error) {
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
}
}
let installed = 0
let failed = 0
for (const store of stores.values()) {
try {
const columns = this.readMessageColumns(store)
if (columns.length === 0) throw new Error('消息表没有可归档列')
this.ensureRecallJournalTable(store, columns)
this.createRecallJournalTrigger(store, columns)
installed += 1
} catch (error) {
failed += 1
console.warn(
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
error
)
}
}
return { installed, failed }
}
private listMessageStores(username: string): Wcdb4MessageStore[] {
if (!this.wcdbGetMessageTableStats) return []
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
return (Array.isArray(rows) ? rows : [])
.map((row) => ({
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
}))
.filter((row) => row.tableName && row.dbPath)
}
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
)
return Array.isArray(rows) ? rows : []
}
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
const rows = this.executeMessageSql(
store,
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
)
return rows
.map((row) => {
const name = this.pickString(row, ['name'])
const type = this.pickString(row, ['type']).toUpperCase()
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
return { name, declaration }
})
.filter((column) => column.name)
}
private ensureRecallJournalTable(
store: Wcdb4MessageStore,
columns: { name: string; declaration: string }[]
): void {
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
const definitions = columns
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
.join(', ')
this.executeMessageSql(
store,
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
)
}
private createRecallJournalTrigger(
store: Wcdb4MessageStore,
columns: { name: string; declaration: string }[]
): void {
const table = this.quoteSqlIdentifier(store.tableName)
const triggerName = this.quoteSqlIdentifier(
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
)
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
const targetColumns = [
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
'"_wxe_source_table"',
'"_wxe_captured_at"'
].join(', ')
const sourceValues = [
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
`'${store.tableName.replace(/'/g, "''")}'`,
`CAST(strftime('%s', 'now') AS INTEGER)`
].join(', ')
this.executeMessageSql(
store,
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
)
}
private readRecallJournal(
username: string,
startTime?: number,
endTime?: number
): Wcdb4Message[] {
const recoveredRows: Record<string, unknown>[] = []
for (const store of this.listMessageStores(username)) {
try {
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const where = [
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
begin > 0 ? `"create_time" >= ${begin}` : '',
end > 0 ? `"create_time" <= ${end}` : ''
].filter(Boolean)
const rows = this.executeMessageSql(
store,
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
)
recoveredRows.push(...rows)
} catch {
// The journal is optional until installation has completed for this store.
}
}
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
}
private mergeMessageRows(
current: Wcdb4Message[],
recovered: Wcdb4Message[],
limit?: number
): Wcdb4Message[] {
const merged = new Map<string, Wcdb4Message>()
for (const message of [...recovered, ...current]) {
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
const identity = message.mesLocalID
? `local:${message.mesLocalID}`
: message.serverId
? `server:${message.serverId}`
: `${message.msgCreateTime}:${message.msgContent}`
const key = recoveredRow ? `recovered:${identity}` : identity
merged.set(key, message)
}
const messages = Array.from(merged.values()).sort(
(left, right) =>
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
)
return limit && messages.length > limit ? messages.slice(-limit) : messages
}
private recallJournalTableName(messageTableName: string): string {
return `_wxe_recall_journal_${crypto
.createHash('sha1')
.update(messageTableName)
.digest('hex')
.slice(0, 16)}`
}
getMyAvatarUrl(): string | undefined {
const candidates = this.getMyUsernameCandidates()
this.hydrateAvatarUrls(candidates)
@@ -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),
+9
View File
@@ -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%;
@@ -56,6 +56,11 @@ export function MessageBubble({
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
)}
</div>
{message.recalled && (
<span className="message-recalled-status" title={message.recalledBy || undefined}>
</span>
)}
<span className="message-hover-time">{hoverTime}</span>
{!isGroupChat && !isMine && contact.m_nsNickName && (
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
+17 -1
View File
@@ -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 =