mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +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 { 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 })
|
||||
}
|
||||
|
||||
@@ -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') || ''
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
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),
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user