mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 添加微信消息防撤回功能
This commit is contained in:
@@ -71,6 +71,7 @@ import { installSafeConsole } from './safe-log'
|
|||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { appLogger } from './app-logger'
|
import { appLogger } from './app-logger'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
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.
|
// 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
|
// 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 keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
|
|
||||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||||
@@ -220,9 +222,41 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
chat.setChatDb(nextWechatDb)
|
chat.setChatDb(nextWechatDb)
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
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)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||||
|
wcdb4Client.invalidateSessionCache()
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,19 @@ type QuoteContent = {
|
|||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: 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 }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
@@ -88,6 +100,15 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
function parseSystemMessage(content: string): ParsedContent {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
const stripped = stripChatroomPrefix(content)
|
const stripped = stripChatroomPrefix(content)
|
||||||
const decoded = decodeXmlEntities(stripped)
|
const decoded = decodeXmlEntities(stripped)
|
||||||
|
const recall = extractRecallMessage(decoded)
|
||||||
|
if (recall) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: recall.replacement,
|
||||||
|
raw: content,
|
||||||
|
recall
|
||||||
|
}
|
||||||
|
}
|
||||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||||
if (delChatroomMemberText) {
|
if (delChatroomMemberText) {
|
||||||
return {
|
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 {
|
function parseImageMessage(content: string): ParsedContent {
|
||||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
DatabaseKeyValidationCode,
|
DatabaseKeyValidationCode,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -49,8 +50,11 @@ export interface FormattedMessage {
|
|||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
@@ -163,7 +167,7 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
|||||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMessages(
|
function listSourceMessages(
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
@@ -264,8 +268,12 @@ export function listMessages(
|
|||||||
|
|
||||||
if (msgType === 34) content = '[语音消息]'
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
|
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||||
|
|
||||||
return {
|
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',
|
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||||
isSender: isMine,
|
isSender: isMine,
|
||||||
type: displayType,
|
type: displayType,
|
||||||
@@ -276,7 +284,9 @@ export function listMessages(
|
|||||||
senderId,
|
senderId,
|
||||||
sessionId: username,
|
sessionId: username,
|
||||||
localId,
|
localId,
|
||||||
|
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||||
createTime,
|
createTime,
|
||||||
|
recoveredFromRecallJournal,
|
||||||
contentData
|
contentData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -287,6 +297,19 @@ export function listMessages(
|
|||||||
return formatted
|
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 {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,7 @@ export interface Wcdb4Session {
|
|||||||
|
|
||||||
export interface Wcdb4Message {
|
export interface Wcdb4Message {
|
||||||
mesLocalID: string
|
mesLocalID: string
|
||||||
|
serverId?: string
|
||||||
mesDes: number
|
mesDes: number
|
||||||
messageType: string
|
messageType: string
|
||||||
msgCreateTime: string
|
msgCreateTime: string
|
||||||
@@ -30,6 +31,11 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Wcdb4MessageStore = {
|
||||||
|
tableName: string
|
||||||
|
dbPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -307,7 +313,9 @@ export class Wcdb4Client {
|
|||||||
candidates.push(...discoverWindowsDbRoots())
|
candidates.push(...discoverWindowsDbRoots())
|
||||||
return Array.from(new Set(candidates))
|
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[] {
|
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||||
@@ -331,7 +339,9 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
private static hasDbStorage(candidate: string): boolean {
|
private static hasDbStorage(candidate: string): boolean {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -588,6 +598,11 @@ export class Wcdb4Client {
|
|||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateSessionCache(): void {
|
||||||
|
this.cachedSessions = null
|
||||||
|
this.cachedChatTables = null
|
||||||
|
}
|
||||||
|
|
||||||
getChatTables(): { name: string; db_number: string }[] {
|
getChatTables(): { name: string; db_number: string }[] {
|
||||||
if (this.cachedChatTables) return this.cachedChatTables
|
if (this.cachedChatTables) return this.cachedChatTables
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -616,10 +631,12 @@ export class Wcdb4Client {
|
|||||||
try {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages) {
|
||||||
|
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||||
|
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||||
console.log(
|
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) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||||
@@ -648,10 +665,7 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
|
||||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const batch = Array.isArray(rows) ? rows : []
|
const batch = Array.isArray(rows) ? rows : []
|
||||||
@@ -694,17 +708,9 @@ export class Wcdb4Client {
|
|||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: { tableName: string; dbPath: string }[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
tables = this.listMessageStores(username)
|
||||||
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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||||
return []
|
return []
|
||||||
@@ -739,6 +745,172 @@ export class Wcdb4Client {
|
|||||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
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 {
|
getMyAvatarUrl(): string | undefined {
|
||||||
const candidates = this.getMyUsernameCandidates()
|
const candidates = this.getMyUsernameCandidates()
|
||||||
this.hydrateAvatarUrls(candidates)
|
this.hydrateAvatarUrls(candidates)
|
||||||
@@ -856,26 +1028,25 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||||
|
|
||||||
return visibleMessages
|
return visibleMessages.map((message) => {
|
||||||
.map((message) => {
|
if (!message.sender) return message
|
||||||
if (!message.sender) return message
|
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
const shouldPrefixSender =
|
||||||
const shouldPrefixSender =
|
username.endsWith('@chatroom') &&
|
||||||
username.endsWith('@chatroom') &&
|
message.mesDes === 1 &&
|
||||||
message.mesDes === 1 &&
|
message.sender &&
|
||||||
message.sender &&
|
message.msgContent &&
|
||||||
message.msgContent &&
|
!message.msgContent.startsWith(`${message.sender}:`)
|
||||||
!message.msgContent.startsWith(`${message.sender}:`)
|
return {
|
||||||
return {
|
...message,
|
||||||
...message,
|
senderNickname,
|
||||||
senderNickname,
|
senderAvatar,
|
||||||
senderAvatar,
|
msgContent: shouldPrefixSender
|
||||||
msgContent: shouldPrefixSender
|
? `${message.sender}:\n${message.msgContent}`
|
||||||
? `${message.sender}:\n${message.msgContent}`
|
: message.msgContent
|
||||||
: message.msgContent
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessageLimit(limit?: number): number | undefined {
|
private normalizeMessageLimit(limit?: number): number | undefined {
|
||||||
@@ -915,11 +1086,7 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'contact_remark'
|
||||||
])
|
])
|
||||||
const memberNickname = this.pickString(row, [
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
'displayName',
|
|
||||||
'display_name',
|
|
||||||
'name'
|
|
||||||
])
|
|
||||||
const avatar = this.pickString(row, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -933,8 +1100,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname:
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
|
||||||
groupNickname: groupNicknames.get(username) || '',
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
wechatNickname: wechatNickname || memberNickname,
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
remark,
|
remark,
|
||||||
@@ -1520,6 +1686,19 @@ export class Wcdb4Client {
|
|||||||
'mesLocalID',
|
'mesLocalID',
|
||||||
'id'
|
'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, [
|
const messageType = this.pickString(row, [
|
||||||
'local_type',
|
'local_type',
|
||||||
'localType',
|
'localType',
|
||||||
@@ -1541,6 +1720,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||||
|
serverId: serverId || undefined,
|
||||||
mesDes: isSend ? 0 : 1,
|
mesDes: isSend ? 0 : 1,
|
||||||
messageType: messageType || '1',
|
messageType: messageType || '1',
|
||||||
msgCreateTime: String(createTime),
|
msgCreateTime: String(createTime),
|
||||||
|
|||||||
@@ -1296,6 +1296,15 @@ body {
|
|||||||
font-size: 12px;
|
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 {
|
.message-hover-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export function MessageBubble({
|
|||||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{message.recalled && (
|
||||||
|
<span className="message-recalled-status" title={message.recalledBy || undefined}>
|
||||||
|
消息已撤回
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="message-hover-time">{hoverTime}</span>
|
<span className="message-hover-time">{hoverTime}</span>
|
||||||
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||||
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||||
|
|||||||
+17
-1
@@ -20,8 +20,12 @@ export interface Message {
|
|||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
|
recoveredFromRecallJournal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextContent = { type: 'text'; content: string }
|
type TextContent = { type: 'text'; content: string }
|
||||||
@@ -67,7 +71,19 @@ type QuoteContent = {
|
|||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: 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 }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
|
|||||||
Reference in New Issue
Block a user