diff --git a/README.md b/README.md index 7c8f201..5832147 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \ 本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。 +## Star History + + + + + + + + + ## 🔗 参考致谢 - [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer) diff --git a/src/main/index.ts b/src/main/index.ts index 9319c43..2e572f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import './preload-env' +import './preload-env' import { app, shell, @@ -8,10 +8,12 @@ import { clipboard, Menu, Tray, - dialog + dialog, + protocol } from 'electron' import { join } from 'path' -import { existsSync } from 'fs' +import { existsSync, promises as fsPromises } from 'fs' +import { extname } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { WechatDb } from './wechat-db' @@ -71,6 +73,8 @@ 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' +import { VideoAssetService } from './video-asset-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 @@ -80,16 +84,25 @@ installSafeConsole() let voiceService: VoiceService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null +let videoAssetService: VideoAssetService | null = null const databaseKeyStore = new DatabaseKeyStore() const imageKeyConfigService = new ImageKeyConfigService() 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 +protocol.registerSchemesAsPrivileged([ + { + scheme: 'wxe-media', + privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true } + } +]) + // WCDB's Windows runtime checks the host application name during wcdb_init. // Mirroring WeFlow's name unblocks the -1006 init failure on Windows. app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer') @@ -107,8 +120,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } { } } +async function createLocalMediaResponse(request: Request, filePath: string): Promise { + const { size } = await fsPromises.stat(filePath) + const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg' + const commonHeaders = { + 'Accept-Ranges': 'bytes', + 'Content-Type': mimeType, + 'Cache-Control': 'private, max-age=300' + } + const range = request.headers.get('range') + + if (!range) { + const body = + request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath)) + return new Response(body, { + status: 200, + headers: { ...commonHeaders, 'Content-Length': String(size) } + }) + } + + const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim()) + if (!match) { + return new Response(null, { + status: 416, + headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` } + }) + } + const start = Number(match[1]) + const requestedEnd = match[2] ? Number(match[2]) : size - 1 + const end = Math.min(requestedEnd, size - 1) + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) { + return new Response(null, { + status: 416, + headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` } + }) + } + + const length = end - start + 1 + let body: Buffer | null = null + if (request.method !== 'HEAD') { + const handle = await fsPromises.open(filePath, 'r') + try { + body = Buffer.allocUnsafe(length) + await handle.read(body, 0, length, start) + } finally { + await handle.close() + } + } + return new Response(body ? Uint8Array.from(body) : null, { + status: 206, + headers: { + ...commonHeaders, + 'Content-Length': String(length), + 'Content-Range': `bytes ${start}-${end}/${size}` + } + }) +} + function createWindow(): void { - // 鍒涘缓娴忚鍣ㄧ獥鍙? + // 创建浏览器窗口 const mainWindow = new BrowserWindow({ width: 1400, height: 800, @@ -130,8 +200,8 @@ function createWindow(): void { return { action: 'deny' } }) - // 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR - // 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢 + // 基于 electron-vite CLI 的渲染器热更新 + // 加载开发环境的远程 URL,或生产环境的本地 HTML 文件 if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -139,9 +209,20 @@ function createWindow(): void { } } -// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶 -// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢? +// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法 +// 某些 API 只能在此事件发生后使用 app.whenReady().then(async () => { + protocol.handle('wxe-media', async (request) => { + const token = new URL(request.url).pathname.replace(/^\/+/, '') + const filePath = videoAssetService?.pathForToken(token) + if (!filePath) return new Response('Not found', { status: 404 }) + try { + return await createLocalMediaResponse(request, filePath) + } catch (error) { + console.warn('[Video] local media request failed:', error) + return new Response('Media unavailable', { status: 500 }) + } + }) console.log(`WechatExplorer main build: ${BUILD_MARK}`) appLogger.write({ level: 'info', @@ -179,14 +260,14 @@ app.whenReady().then(async () => { console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError) } - // 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID + // 设置应用程序用户模型 ID electronApp.setAppUserModelId('com.wechatexplorer.app') if (process.platform === 'darwin') app.dock?.setIcon(appIconPath) - // 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools - // 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R - // 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + // 开发环境中默认使用 F12 打开或关闭 DevTools + // 生产环境中忽略 CommandOrControl + R + // 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) @@ -220,9 +301,42 @@ 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) + videoAssetService = new VideoAssetService(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 }) } @@ -623,6 +737,15 @@ app.whenReady().then(async () => { return stickerService.resolveSticker(cdnUrl, md5) }) + ipcMain.handle('db:getVideo', async (_, hashes: string[]) => { + if (!videoAssetService) { + const client = chat.getChatDb()?.getWcdb4Client() + if (!client) return { success: false, error: '数据库尚未连接' } + videoAssetService = new VideoAssetService(client) + } + return videoAssetService.resolve(Array.isArray(hashes) ? hashes : []) + }) + // -------- Settings & API service -------- ipcMain.handle('settings:get', () => ({ @@ -752,7 +875,7 @@ app.whenReady().then(async () => { createWindow() - // 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗) + // 启动本地 HTTP API(由 settings.apiEnabled 控制) const settings = loadSettings() if (settings.apiEnabled) { await apiServer.start(settings.apiHost, settings.apiPort) @@ -766,15 +889,15 @@ app.whenReady().then(async () => { } app.on('activate', function () { - // 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝 - // 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€? + // 在 macOS 上点击 Dock 图标且没有其他窗口打开时, + // 通常会在应用程序中重新创建一个窗口。 if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) -// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛? -// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛 -// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€? +// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上, +// 应用程序及其菜单栏通常会保持活动状态,直到用户 +// 明确使用 Cmd + Q 退出。 app.on('window-all-closed', () => { if (TRAY_MODE) return if (process.platform !== 'darwin') { diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index fe0ce9f..caea740 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -24,6 +24,15 @@ type ImageContent = { aeskey?: string encrypVer?: number } +type VideoContent = { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number +} type StickerContent = { type: 'sticker' md5?: string @@ -41,7 +50,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 = @@ -52,6 +73,7 @@ export type ParsedContent = | ShareContent | VoipContent | ImageContent + | VideoContent | StickerContent | QuoteContent | SystemContent @@ -69,6 +91,8 @@ export function parseMessageContent(content: string, messageType: number): Parse return parseImageMessage(normalized) case 42: return parseCardMessage(normalized) + case 43: + return parseVideoMessage(normalized) case 47: return parseStickerMessage(normalized) case 48: @@ -85,9 +109,31 @@ export function parseMessageContent(content: string, messageType: number): Parse } } +function parseVideoMessage(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5')) + const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5')) + const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5')) + if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content } + + const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined + const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined + const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined + return { type: 'video', md5, newMd5, rawMd5, duration, width, height } +} + 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 +159,50 @@ function parseSystemMessage(content: string): ParsedContent { } } +function extractRecallMessage(xml: string): + | { + targetId?: string + targetIds?: string[] + replacement: string + actor?: string + sessionId?: string + recallTime?: number + } + | undefined { + if (!/ let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || '' @@ -433,7 +523,10 @@ function extractXmlValue(xml: string, tagName: string): string { } function extractXmlAttribute(xml: string, tagName: string, attrName: string): string { - const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i') + const pattern = new RegExp( + `<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`, + 'i' + ) const match = xml.match(pattern) return match ? match[1].trim() : '' } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index f1e493f..6d474b0 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -8,6 +8,7 @@ import type { DatabaseKeyValidationCode, DatabaseKeyValidationResult } from '../../shared/database-key' +import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' export function getCurrentKey(): string { if (!dbRef) return '' @@ -49,8 +50,11 @@ export interface FormattedMessage { voiceDataUrl?: string voiceDuration?: number localId?: number + serverId?: string createTime?: number sessionId?: string + recalled?: boolean + recalledBy?: string } export interface GroupSnapshot { @@ -163,7 +167,7 @@ export function getContactAvatars(usernames: string[]): Record { return dbRef.getWcdb4Client().getAvatarUrls(normalized) } -export function listMessages( +function listSourceMessages( userMd5: string, startTime?: number, endTime?: number, @@ -221,7 +225,7 @@ export function listMessages( / +} + +const ARCHIVE_VERSION = 1 +const MAX_MESSAGES_PER_SESSION = 3000 +const MAX_RECALLS_PER_SESSION = 500 +const WRITE_DEBOUNCE_MS = 250 + +let archive: RecallArchiveFile | null = null +let archivePath = '' +let writeTimer: NodeJS.Timeout | null = null +let writeQueue: Promise = Promise.resolve() + +function messageIdentity(message: Message): string { + if (message.recoveredFromRecallJournal) { + return `recovered:${message.localId || 0}:${message.serverId || message.id}` + } + if (message.localId) return `local:${message.localId}` + if (message.serverId) return `server:${message.serverId}` + if (message.id) return `id:${message.id}` + return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` +} + +function messageTargetIds(message: Message): string[] { + return Array.from( + new Set( + [ + message.serverId, + message.localId ? String(message.localId) : '', + message.id ? String(message.id) : '' + ].filter((value): value is string => Boolean(value)) + ) + ) +} + +function isRecallNotice(message: Message): boolean { + return message.contentData?.type === 'system' && Boolean(message.contentData.recall) +} + +function matchesRecall(message: Message, recall: RecallRecord): boolean { + const messageIds = messageTargetIds(message) + return recall.targetIds.some((targetId) => messageIds.includes(targetId)) +} + +function inferRecallTarget( + messages: Iterable, + notice: Message, + actor?: string, + claimedTargetIds: Set = new Set() +): ArchivedMessage | undefined { + const noticeTime = notice.createTime || 0 + const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content) + const eligible = Array.from(messages) + .filter((message) => { + if (message.type === '系统消息' || message.from === 'system' || message.recallNotice) + return false + if (!message.recoveredFromRecallJournal) return false + if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false + if (noticeTime && message.createTime && message.createTime > noticeTime) return false + if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false + return true + }) + .sort((left, right) => { + const timeDelta = (right.createTime || 0) - (left.createTime || 0) + if (timeDelta) return timeDelta + return (right.localId || 0) - (left.localId || 0) + }) + + const strict = eligible.find((message) => { + if (expectsMine) return message.isSender + if (!actor) return true + const names = [message.name, message.senderId].map((value) => String(value || '').trim()) + return !names.some(Boolean) || names.includes(actor) + }) + + // Journal rows are captured at deletion time, so the fallback never points at + // an unrelated visible message merely because it happened to be nearby. + return strict || eligible[0] +} + +function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message { + if (isRecallNotice(message)) return message + const recall = recalls.find((item) => matchesRecall(message, item)) + if (!recall) return message + const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim()) + return { + ...message, + from: recalledByMe ? 'assistant' : message.from, + isSender: recalledByMe ? true : message.isSender, + recalled: true, + recalledBy: recall.actor + } +} + +function scheduleWrite(): void { + if (!archive || !archivePath) return + if (writeTimer) clearTimeout(writeTimer) + writeTimer = setTimeout(() => { + writeTimer = null + if (!archive || !archivePath) return + const targetPath = archivePath + const serialized = JSON.stringify(archive) + writeQueue = writeQueue + .catch(() => undefined) + .then(async () => { + await fs.ensureDir(path.dirname(targetPath)) + const temporaryPath = `${targetPath}.tmp` + await fs.writeFile(temporaryPath, serialized, 'utf8') + await fs.move(temporaryPath, targetPath, { overwrite: true }) + }) + .catch((error) => { + console.warn('[RecallArchive] write failed:', error) + }) + }, WRITE_DEBOUNCE_MS) +} + +function loadArchive(accountRoot: string): RecallArchiveFile { + const hash = crypto + .createHash('sha1') + .update(`${process.platform}:${accountRoot}`) + .digest('hex') + .slice(0, 16) + archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`) + + try { + if (fs.existsSync(archivePath)) { + const stored = fs.readJsonSync(archivePath) as Partial + if ( + stored.version === ARCHIVE_VERSION && + stored.accountRoot === accountRoot && + stored.sessions && + typeof stored.sessions === 'object' + ) { + return stored as RecallArchiveFile + } + } + } catch (error) { + console.warn('[RecallArchive] read failed:', error) + } + + return { + version: ARCHIVE_VERSION, + accountRoot, + updatedAt: Date.now(), + sessions: {} + } +} + +export function configureRecallArchive(accountRoot: string): void { + const normalizedRoot = String(accountRoot || '').trim() + if (!normalizedRoot) { + archive = null + archivePath = '' + return + } + if (archive?.accountRoot === normalizedRoot) return + archive = loadArchive(normalizedRoot) +} + +export function recordRecallArchiveMessages( + sessionMd5: string, + username: string, + messages: Message[] +): void { + if (!archive || !sessionMd5 || messages.length === 0) return + + const bucket = + archive.sessions[sessionMd5] || + ({ + username, + updatedAt: Date.now(), + messages: [], + recalls: [] + } satisfies SessionArchive) + if (username) bucket.username = username + + const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message])) + let changed = false + const targetlessNoticeIds = new Set( + messages + .filter((message) => { + const recall = + message.contentData?.type === 'system' ? message.contentData.recall : undefined + return Boolean(recall && !(recall.targetIds?.length || recall.targetId)) + }) + .map(messageIdentity) + ) + if (targetlessNoticeIds.size > 0) { + const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId)) + if (retained.length !== bucket.recalls.length) { + bucket.recalls = retained + changed = true + } + } + + for (const sourceMessage of messages) { + const recallData = + sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined + if (recallData) { + let targetIds = Array.from( + new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean)) + ) + const noticeId = messageIdentity(sourceMessage) + const noticeTargetIds = new Set(messageTargetIds(sourceMessage)) + const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId) + const existingRecord = + existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined + const existingTargetsNotice = + existingRecord && + existingRecord.targetIds.length > 0 && + existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId)) + + if ( + targetIds.length === 0 && + existingRecord && + !existingTargetsNotice && + !targetlessNoticeIds.has(noticeId) + ) { + targetIds = existingRecord.targetIds + } + if (targetIds.length === 0) { + const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds)) + const inferred = inferRecallTarget( + byIdentity.values(), + sourceMessage, + recallData.actor, + claimedTargetIds + ) + if (inferred) targetIds = messageTargetIds(inferred) + } + if (targetIds.length > 0) { + const nextRecord: RecallRecord = { + targetIds, + actor: recallData.actor, + noticeId, + createTime: sourceMessage.createTime || 0 + } + const unchanged = + existingRecord && + existingRecord.targetIds.length === targetIds.length && + existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) && + existingRecord.actor === nextRecord.actor + if (!unchanged) { + if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord) + else bucket.recalls.push(nextRecord) + changed = true + } + } + } + + const annotated = annotateFromRecall(sourceMessage, bucket.recalls) + const next: ArchivedMessage = { + ...annotated, + archivedAt: Date.now(), + recallNotice: isRecallNotice(sourceMessage) + } + const identity = messageIdentity(next) + const previous = byIdentity.get(identity) + if ( + !previous || + previous.recalled !== next.recalled || + previous.content !== next.content || + previous.serverId !== next.serverId + ) { + byIdentity.set(identity, next) + changed = true + } + } + + const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION) + const nextMessages = Array.from(byIdentity.values()) + .map((message) => annotateFromRecall(message, recalls) as ArchivedMessage) + .sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right)) + }) + .slice(-MAX_MESSAGES_PER_SESSION) + + if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return + + bucket.messages = nextMessages + bucket.recalls = recalls + bucket.updatedAt = Date.now() + archive.sessions[sessionMd5] = bucket + archive.updatedAt = Date.now() + scheduleWrite() +} + +export function mergeRecallArchiveMessages( + sessionMd5: string, + sourceMessages: T[], + startTime?: number, + endTime?: number, + limit?: number +): T[] { + const bucket = archive?.sessions[sessionMd5] + if (!bucket) return sourceMessages + + const merged = new Map() + for (const message of sourceMessages) { + const archived = bucket.messages.find( + (candidate) => messageIdentity(candidate) === messageIdentity(message) + ) + merged.set( + messageIdentity(message), + annotateFromRecall( + archived?.recalled + ? { ...message, recalled: true, recalledBy: archived.recalledBy } + : message, + bucket.recalls + ) + ) + } + + for (const archived of bucket.messages) { + if (!archived.recalled && !archived.recallNotice) continue + const createTime = archived.createTime || 0 + if (startTime && createTime < startTime) continue + if (endTime && createTime > endTime) continue + const identity = messageIdentity(archived) + if (!merged.has(identity)) merged.set(identity, archived) + } + + const result = Array.from(merged.values()).sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right)) + }) + const visible = limit && result.length > limit ? result.slice(-limit) : result + return visible as T[] +} + +export function extractChangedConversationMd5(payload: string): string[] { + const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || [] + return Array.from( + new Set( + matches + .map((value) => value.replace(/^chat_/i, '').toLowerCase()) + .filter((value) => /^[a-f0-9]{32}$/.test(value)) + ) + ) +} + +type ArchiveContact = { + md5: string + m_nsUsrName: string + type: 'user' | 'group' + activityKey?: string +} + +export class RecallArchiveMonitor { + private pending = new Set() + private activityBySession = new Map() + private timer: NodeJS.Timeout | null = null + private stopped = false + + constructor( + private readonly listContacts: () => ArchiveContact[], + private readonly loadMessages: (sessionMd5: string) => Message[] + ) {} + + seedAll(): void { + const groups = this.conversationContacts() + this.activityBySession = new Map( + groups.map((contact) => [contact.md5, String(contact.activityKey || '')]) + ) + const allowed = new Set(groups.map((contact) => contact.md5)) + if (archive) { + let pruned = false + for (const sessionMd5 of Object.keys(archive.sessions)) { + if (allowed.has(sessionMd5)) continue + delete archive.sessions[sessionMd5] + pruned = true + } + if (pruned) { + archive.updatedAt = Date.now() + scheduleWrite() + } + } + } + + handleDatabaseChange(payload: string): void { + const groups = this.conversationContacts() + const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase())) + const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5)) + const changedActivity = groups + .filter((contact) => { + const nextKey = String(contact.activityKey || '') + const previousKey = this.activityBySession.get(contact.md5) + this.activityBySession.set(contact.md5, nextKey) + return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey) + }) + .map((contact) => contact.md5) + const targets = Array.from(new Set([...payloadTargets, ...changedActivity])) + if (targets.length > 0) { + console.log(`[RecallArchive] database change queued=${targets.length}`) + this.enqueue(targets) + } + } + + private conversationContacts(): ArchiveContact[] { + return this.listContacts().filter( + (contact) => + contact.m_nsUsrName && + !contact.m_nsUsrName.startsWith('@placeholder') && + contact.m_nsUsrName !== 'brandsessionholder' && + contact.m_nsUsrName !== 'brandservicesessionholder' + ) + } + + stop(): void { + this.stopped = true + this.pending.clear() + if (this.timer) clearTimeout(this.timer) + this.timer = null + } + + private enqueue(sessionMd5s: string[]): void { + if (this.stopped) return + for (const md5 of sessionMd5s) { + if (md5) this.pending.add(md5) + } + this.schedule() + } + + private schedule(): void { + if (this.stopped || this.timer || this.pending.size === 0) return + this.timer = setTimeout(() => { + this.timer = null + const md5 = this.pending.values().next().value as string | undefined + if (!md5) return + this.pending.delete(md5) + try { + this.loadMessages(md5) + } catch (error) { + console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error) + } + this.schedule() + }, 20) + } +} diff --git a/src/main/video-asset-service.ts b/src/main/video-asset-service.ts new file mode 100644 index 0000000..16000b2 --- /dev/null +++ b/src/main/video-asset-service.ts @@ -0,0 +1,104 @@ +import fs from 'fs-extra' +import path from 'path' +import crypto from 'crypto' +import type { Wcdb4Client } from './wcdb4-client' + +type VideoAsset = { + filePath: string + posterPath?: string +} + +export class VideoAssetService { + private readonly urlTokens = new Map() + private index: Map | null = null + + constructor(private readonly client: Wcdb4Client) {} + + resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } { + const candidates = Array.from( + new Set( + hashes + .map((value) => + String(value || '') + .trim() + .toLowerCase() + ) + .filter((value) => /^[a-f0-9]{32}$/.test(value)) + ) + ) + if (candidates.length === 0) return { success: false, error: '视频标识为空' } + + const hardlinkDb = path.join( + this.client.getAccountRoot(), + 'db_storage', + 'hardlink', + 'hardlink.db' + ) + const lookupKeys = [...candidates] + if (fs.existsSync(hardlinkDb)) { + for (const hash of candidates) { + const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5 + if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase()) + } + } + + const index = this.getIndex() + for (const key of lookupKeys) { + const asset = index.get(key) || index.get(`${key}_raw`) + if (!asset) continue + return { + success: true, + url: this.createUrl(asset.filePath), + poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined + } + } + return { success: false, error: '本地未找到该视频文件' } + } + + pathForToken(token: string): string | undefined { + const filePath = this.urlTokens.get(token) + if (!filePath || !fs.existsSync(filePath)) return undefined + return filePath + } + + private createUrl(filePath: string): string { + const token = crypto.randomBytes(18).toString('hex') + this.urlTokens.set(token, filePath) + if (this.urlTokens.size > 500) { + const first = this.urlTokens.keys().next().value + if (first) this.urlTokens.delete(first) + } + return `wxe-media://local/${token}` + } + + private getIndex(): Map { + if (this.index) return this.index + const result = new Map() + const root = path.join(this.client.getAccountRoot(), 'msg', 'video') + if (!fs.existsSync(root)) { + this.index = result + return result + } + + for (const month of fs.readdirSync(root)) { + const monthPath = path.join(root, month) + if (!fs.statSync(monthPath).isDirectory()) continue + for (const name of fs.readdirSync(monthPath)) { + const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name) + if (!match) continue + const key = `${match[1].toLowerCase()}${match[2] || ''}` + const fullPath = path.join(monthPath, name) + const existing = result.get(key) || { filePath: '' } + if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath + else if (!existing.posterPath) existing.posterPath = fullPath + result.set(key, existing) + } + } + + for (const [key, asset] of result) { + if (!asset.filePath) result.delete(key) + } + this.index = result + return result + } +} diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 876a3ce..dcf065f 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -16,6 +16,7 @@ export interface Wcdb4Session { export interface Wcdb4Message { mesLocalID: string + serverId?: string mesDes: number messageType: string msgCreateTime: string @@ -30,6 +31,11 @@ export interface Wcdb4MessageQueryOptions { limit?: number } +type Wcdb4MessageStore = { + tableName: string + dbPath: string +} + export interface Wcdb4GroupMember { m_nsUsrName: string nickname: string @@ -45,6 +51,11 @@ export interface Wcdb4ImageHardlink { [key: string]: unknown } +export interface Wcdb4VideoHardlink { + resolved_md5?: string + [key: string]: unknown +} + type KoffiModule = { load: (libraryPath: string) => KoffiLibrary decode: (ptr: unknown, type: string, length: number) => string @@ -217,6 +228,9 @@ export class Wcdb4Client { private wcdbResolveImageHardlink: | ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number) | null = null + private wcdbResolveVideoHardlink: + | ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number) + | null = null private wcdbGetEmoticonCdnUrl: | ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number) | null = null @@ -307,7 +321,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 +347,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 +606,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 +639,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 +673,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 +716,9 @@ export class Wcdb4Client { ): Wcdb4Message[] { if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] - let tables: { tableName: string; dbPath: string }[] = [] + let tables: Wcdb4MessageStore[] = [] try { - const rows = this.callJson[]>((handle, outJson) => - this.wcdbGetMessageTableStats!(handle, username, outJson) - ) - tables = (Array.isArray(rows) ? rows : []) - .map((row) => ({ - tableName: this.pickString(row, ['table_name', 'tableName', 'name']), - dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) - })) - .filter((row) => row.tableName && row.dbPath) + tables = this.listMessageStores(username) } catch (error) { console.warn(`[WCDB4] message table stats failed username=${username}:`, error) return [] @@ -739,6 +753,172 @@ export class Wcdb4Client { return this.finalizeMessages(username, allRows, startTime, endTime, limit) } + installRecallJournal(usernames: string[]): { installed: number; failed: number } { + const stores = new Map() + for (const username of this.uniq(usernames)) { + try { + for (const store of this.listMessageStores(username)) { + stores.set(`${store.dbPath}\u0000${store.tableName}`, store) + } + } catch (error) { + console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error) + } + } + + let installed = 0 + let failed = 0 + for (const store of stores.values()) { + try { + const columns = this.readMessageColumns(store) + if (columns.length === 0) throw new Error('消息表没有可归档列') + this.ensureRecallJournalTable(store, columns) + this.createRecallJournalTrigger(store, columns) + installed += 1 + } catch (error) { + failed += 1 + console.warn( + `[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`, + error + ) + } + } + return { installed, failed } + } + + private listMessageStores(username: string): Wcdb4MessageStore[] { + if (!this.wcdbGetMessageTableStats) return [] + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetMessageTableStats!(handle, username, outJson) + ) + return (Array.isArray(rows) ? rows : []) + .map((row) => ({ + tableName: this.pickString(row, ['table_name', 'tableName', 'name']), + dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) + })) + .filter((row) => row.tableName && row.dbPath) + } + + private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record[] { + if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') + const rows = this.callJson[]>((handle, outJson) => + this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson) + ) + return Array.isArray(rows) ? rows : [] + } + + private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] { + const rows = this.executeMessageSql( + store, + `PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})` + ) + return rows + .map((row) => { + const name = this.pickString(row, ['name']) + const type = this.pickString(row, ['type']).toUpperCase() + const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB' + return { name, declaration } + }) + .filter((column) => column.name) + } + + private ensureRecallJournalTable( + store: Wcdb4MessageStore, + columns: { name: string; declaration: string }[] + ): void { + const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) + const definitions = columns + .map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`) + .join(', ') + this.executeMessageSql( + store, + `CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)` + ) + } + + private createRecallJournalTrigger( + store: Wcdb4MessageStore, + columns: { name: string; declaration: string }[] + ): void { + const table = this.quoteSqlIdentifier(store.tableName) + const triggerName = this.quoteSqlIdentifier( + `_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}` + ) + const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName)) + const targetColumns = [ + ...columns.map((column) => this.quoteSqlIdentifier(column.name)), + '"_wxe_source_table"', + '"_wxe_captured_at"' + ].join(', ') + const sourceValues = [ + ...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`), + `'${store.tableName.replace(/'/g, "''")}'`, + `CAST(strftime('%s', 'now') AS INTEGER)` + ].join(', ') + this.executeMessageSql( + store, + `CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END` + ) + } + + private readRecallJournal( + username: string, + startTime?: number, + endTime?: number + ): Wcdb4Message[] { + const recoveredRows: Record[] = [] + for (const store of this.listMessageStores(username)) { + try { + const begin = this.normalizeTimestamp(startTime || 0) + const end = this.normalizeTimestamp(endTime || 0) + const where = [ + `"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`, + begin > 0 ? `"create_time" >= ${begin}` : '', + end > 0 ? `"create_time" <= ${end}` : '' + ].filter(Boolean) + const rows = this.executeMessageSql( + store, + `SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500` + ) + recoveredRows.push(...rows) + } catch { + // The journal is optional until installation has completed for this store. + } + } + return this.finalizeMessages(username, recoveredRows, startTime, endTime) + } + + private mergeMessageRows( + current: Wcdb4Message[], + recovered: Wcdb4Message[], + limit?: number + ): Wcdb4Message[] { + const merged = new Map() + for (const message of [...recovered, ...current]) { + const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) + const identity = message.mesLocalID + ? `local:${message.mesLocalID}` + : message.serverId + ? `server:${message.serverId}` + : `${message.msgCreateTime}:${message.msgContent}` + const key = recoveredRow ? `recovered:${identity}` : identity + merged.set(key, message) + } + const messages = Array.from(merged.values()).sort( + (left, right) => + Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) || + Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0) + ) + return limit && messages.length > limit ? messages.slice(-limit) : messages + } + + private recallJournalTableName(messageTableName: string): string { + return `_wxe_recall_journal_${crypto + .createHash('sha1') + .update(messageTableName) + .digest('hex') + .slice(0, 16)}` + } + getMyAvatarUrl(): string | undefined { const candidates = this.getMyUsernameCandidates() this.hydrateAvatarUrls(candidates) @@ -856,26 +1036,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 +1094,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 +1108,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, @@ -1073,6 +1247,23 @@ export class Wcdb4Client { } } + resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null { + if (!this.wcdbResolveVideoHardlink) return null + const normalizedMd5 = String(md5 || '') + .trim() + .toLowerCase() + if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null + + try { + return this.callJson((handle, outJson) => + this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson) + ) + } catch (error) { + console.warn('[WCDB4] resolve video hardlink failed:', error) + return null + } + } + resolveEmoticonCdnUrl(md5: string): string | undefined { if (!this.wcdbGetEmoticonCdnUrl) { console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`) @@ -1286,6 +1477,14 @@ export class Wcdb4Client { this.wcdbResolveImageHardlink = null } + try { + this.wcdbResolveVideoHardlink = lib.func( + 'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)' + ) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbResolveVideoHardlink = null + } + try { this.wcdbGetEmoticonCdnUrl = lib.func( 'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)' @@ -1520,6 +1719,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 +1753,7 @@ export class Wcdb4Client { return { mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`, + serverId: serverId || undefined, mesDes: isSend ? 0 : 1, messageType: messageType || '1', msgCreateTime: String(createTime), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bdc9810..e304a26 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -48,6 +48,15 @@ export type ParsedContent = | { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string } | { type: 'voip'; duration?: number; status: string; roomType?: number } | { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number } + | { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number + } | { type: 'sticker' md5?: string @@ -151,6 +160,9 @@ declare global { isThumb?: boolean filePath?: string }> + getVideo: ( + hashes: string[] + ) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }> getSticker: ( cdnUrl?: string, md5?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index f366f15..a6e9744 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,6 +59,7 @@ const api = { sessionId?: string, options?: { force?: boolean } ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), + getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), exportGroupReport: (request: GroupReportExportRequest) => ipcRenderer.invoke('report:export', request), diff --git a/src/renderer/index.html b/src/renderer/index.html index 8bf886e..43010af 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,7 @@ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cde51ef..247fefe 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState } from 'react' import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' import { AppShell } from './components/layout/AppShell' diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 393d89f..1d62bb4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1296,6 +1296,15 @@ body { font-size: 12px; } +.message-recalled-status { + align-self: flex-end; + margin-top: 3px; + color: #c63c32; + font-size: 11px; + font-weight: 600; + line-height: 16px; +} + .message-hover-time { position: absolute; top: 50%; @@ -1366,6 +1375,54 @@ body { display: none; } +.video-message-bubble { + padding: 0; + overflow: hidden; + background: #101412; + border-color: rgba(0, 0, 0, 0.12); +} + +.wechat-message-row.mine .video-message-bubble { + background: #101412; +} + +.video-message { + position: relative; + width: min(320px, 48vw); + min-height: 140px; + background: #101412; +} + +.video-content { + display: block; + width: 100%; + max-height: 360px; + object-fit: contain; + background: #101412; +} + +.video-duration { + position: absolute; + right: 8px; + top: 8px; + padding: 2px 6px; + border-radius: 8px; + color: #fff; + background: rgba(0, 0, 0, 0.55); + font-size: 11px; + pointer-events: none; +} + +.video-placeholder { + display: grid; + place-items: center; + width: min(260px, 44vw); + min-height: 120px; + padding: 16px; + color: rgba(255, 255, 255, 0.78); + text-align: center; +} + .image-bubble { position: relative; max-width: min(260px, 46vw); diff --git a/src/renderer/src/components/VideoBubble.tsx b/src/renderer/src/components/VideoBubble.tsx new file mode 100644 index 0000000..f503f94 --- /dev/null +++ b/src/renderer/src/components/VideoBubble.tsx @@ -0,0 +1,53 @@ +import { useEffect, useMemo, useState } from 'react' + +interface VideoBubbleProps { + md5?: string + newMd5?: string + rawMd5?: string + duration?: number +} + +export function VideoBubble({ + md5, + newMd5, + rawMd5, + duration +}: VideoBubbleProps): React.ReactElement { + const hashes = useMemo( + () => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)), + [md5, newMd5, rawMd5] + ) + const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({}) + + useEffect(() => { + let cancelled = false + window.api + .getVideo(hashes) + .then((result) => { + if (!cancelled) setMedia(result.success ? result : { error: result.error }) + }) + .catch((error) => { + if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) }) + }) + return () => { + cancelled = true + } + }, [hashes]) + + if (!media.url) { + return {media.error || '视频加载中…'} + } + + return ( + + + {duration ? {duration} 秒 : null} + + ) +} diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 9d5df9c..09bf21d 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types' import { ImageBubble } from '../ImageBubble' import { RichMessageBubble } from '../RichMessageBubble' import { VoicePlayer } from '../VoicePlayer' +import { VideoBubble } from '../VideoBubble' import { renderWechatEmojiText } from '../../utils/wechatEmojiText' import { formatMessageTime } from './messageGrouping' @@ -27,6 +28,7 @@ export function MessageBubble({ }: MessageBubbleProps): React.ReactElement { const isVoice = message.type === '语音' const isImage = message.type === '图片' + const isVideo = message.type === '视频' const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type) const hoverTime = formatMessageTime(message) @@ -35,7 +37,7 @@ export function MessageBubble({ {isVoice && message.sessionId ? ( + ) : isVideo && message.contentData && message.contentData.type === 'video' ? ( + ) : isRichMedia && message.contentData ? ( ) : ( {renderWechatEmojiText(message.content)} )} + {message.recalled && ( + + 消息已撤回 + + )} {hoverTime} {!isGroupChat && !isMine && contact.m_nsNickName && ( {contact.m_nsNickName} diff --git a/src/shared/types.ts b/src/shared/types.ts index 2bf8c61..d32c484 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,8 +20,12 @@ export interface Message { voiceDataUrl?: string voiceDuration?: number localId?: number + serverId?: string createTime?: number sessionId?: string + recalled?: boolean + recalledBy?: string + recoveredFromRecallJournal?: boolean } type TextContent = { type: 'text'; content: string } @@ -50,6 +54,15 @@ type ImageContent = { aeskey?: string encrypVer?: number } +type VideoContent = { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number +} type StickerContent = { type: 'sticker' md5?: string @@ -67,7 +80,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 = @@ -78,6 +103,7 @@ export type ParsedContent = | ShareContent | VoipContent | ImageContent + | VideoContent | StickerContent | QuoteContent | SystemContent