mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
Merge branch 'develop'
This commit is contained in:
@@ -158,6 +158,16 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
|||||||
|
|
||||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
## 🔗 参考致谢
|
## 🔗 参考致谢
|
||||||
|
|
||||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||||
|
|||||||
+141
-18
@@ -1,4 +1,4 @@
|
|||||||
import './preload-env'
|
import './preload-env'
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
shell,
|
shell,
|
||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
Menu,
|
Menu,
|
||||||
Tray,
|
Tray,
|
||||||
dialog
|
dialog,
|
||||||
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { join } from 'path'
|
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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
@@ -71,6 +73,8 @@ import { installSafeConsole } from './safe-log'
|
|||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { appLogger } from './app-logger'
|
import { appLogger } from './app-logger'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||||
|
import { VideoAssetService } from './video-asset-service'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -80,16 +84,25 @@ installSafeConsole()
|
|||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
let imageDecryptService: ImageDecryptService | null = null
|
let imageDecryptService: ImageDecryptService | null = null
|
||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
|
let videoAssetService: VideoAssetService | null = null
|
||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
|
|
||||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||||
|
|
||||||
|
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.
|
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
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<Response> {
|
||||||
|
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 {
|
function createWindow(): void {
|
||||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
// 创建浏览器窗口
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -130,8 +200,8 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
// 基于 electron-vite CLI 的渲染器热更新
|
||||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
@@ -139,9 +209,20 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
// 某些 API 只能在此事件发生后使用
|
||||||
app.whenReady().then(async () => {
|
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}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
appLogger.write({
|
appLogger.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -179,14 +260,14 @@ app.whenReady().then(async () => {
|
|||||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
// 设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||||
|
|
||||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||||
|
|
||||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
// 开发环境中默认使用 F12 打开或关闭 DevTools
|
||||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
// 生产环境中忽略 CommandOrControl + R
|
||||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
@@ -220,9 +301,42 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
chat.setChatDb(nextWechatDb)
|
chat.setChatDb(nextWechatDb)
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
|
configureRecallArchive(resolvedRoot)
|
||||||
|
recallArchiveMonitor?.stop()
|
||||||
|
recallArchiveMonitor = new RecallArchiveMonitor(
|
||||||
|
() =>
|
||||||
|
wcdb4Client.getSessions().map((session) => ({
|
||||||
|
md5: wcdb4Client.md5(session.username),
|
||||||
|
m_nsUsrName: session.username,
|
||||||
|
type: session.username.endsWith('@chatroom') ? 'group' : 'user',
|
||||||
|
activityKey: [
|
||||||
|
session.raw['last_timestamp'],
|
||||||
|
session.raw['sort_timestamp'],
|
||||||
|
session.raw['last_msg_locald_id'],
|
||||||
|
session.raw['last_msg_type'],
|
||||||
|
session.raw['summary']
|
||||||
|
].join(':')
|
||||||
|
})),
|
||||||
|
(sessionMd5) =>
|
||||||
|
chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, {
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
)
|
||||||
|
recallArchiveMonitor.seedAll()
|
||||||
|
setTimeout(() => {
|
||||||
|
const result = wcdb4Client.installRecallJournal(
|
||||||
|
wcdb4Client.getSessions().map((session) => session.username)
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||||
|
wcdb4Client.invalidateSessionCache()
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
@@ -623,6 +737,15 @@ app.whenReady().then(async () => {
|
|||||||
return stickerService.resolveSticker(cdnUrl, md5)
|
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 --------
|
// -------- Settings & API service --------
|
||||||
|
|
||||||
ipcMain.handle('settings:get', () => ({
|
ipcMain.handle('settings:get', () => ({
|
||||||
@@ -752,7 +875,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
if (settings.apiEnabled) {
|
if (settings.apiEnabled) {
|
||||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
@@ -766,15 +889,15 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
// 通常会在应用程序中重新创建一个窗口。
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
|
||||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
// 明确使用 Cmd + Q 退出。
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (TRAY_MODE) return
|
if (TRAY_MODE) return
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ type ImageContent = {
|
|||||||
aeskey?: string
|
aeskey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
}
|
}
|
||||||
|
type VideoContent = {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
type StickerContent = {
|
type StickerContent = {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -41,7 +50,19 @@ type QuoteContent = {
|
|||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: string
|
quotedType?: string
|
||||||
}
|
}
|
||||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
type SystemContent = {
|
||||||
|
type: 'system'
|
||||||
|
content: string
|
||||||
|
raw?: string
|
||||||
|
recall?: {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
@@ -52,6 +73,7 @@ export type ParsedContent =
|
|||||||
| ShareContent
|
| ShareContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
|
| VideoContent
|
||||||
| StickerContent
|
| StickerContent
|
||||||
| QuoteContent
|
| QuoteContent
|
||||||
| SystemContent
|
| SystemContent
|
||||||
@@ -69,6 +91,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
return parseCardMessage(normalized)
|
return parseCardMessage(normalized)
|
||||||
|
case 43:
|
||||||
|
return parseVideoMessage(normalized)
|
||||||
case 47:
|
case 47:
|
||||||
return parseStickerMessage(normalized)
|
return parseStickerMessage(normalized)
|
||||||
case 48:
|
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 {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
const stripped = stripChatroomPrefix(content)
|
const stripped = stripChatroomPrefix(content)
|
||||||
const decoded = decodeXmlEntities(stripped)
|
const decoded = decodeXmlEntities(stripped)
|
||||||
|
const recall = extractRecallMessage(decoded)
|
||||||
|
if (recall) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: recall.replacement,
|
||||||
|
raw: content,
|
||||||
|
recall
|
||||||
|
}
|
||||||
|
}
|
||||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||||
if (delChatroomMemberText) {
|
if (delChatroomMemberText) {
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +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 (!/<revokemsg\b/i.test(xml)) return undefined
|
||||||
|
|
||||||
|
const replacement = normalizeSystemText(
|
||||||
|
extractXmlValue(xml, 'replacemsg') ||
|
||||||
|
extractXmlNodeText(xml, 'replacemsg') ||
|
||||||
|
extractXmlValue(xml, 'content') ||
|
||||||
|
extractXmlNodeText(xml, 'content')
|
||||||
|
)
|
||||||
|
if (!replacement) return undefined
|
||||||
|
|
||||||
|
const actorMatch =
|
||||||
|
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
|
||||||
|
/^(.+?)\s*撤回了一条消息/.exec(replacement)
|
||||||
|
|
||||||
|
const targetIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
extractXmlValue(xml, 'newmsgid'),
|
||||||
|
extractXmlValue(xml, 'msgid'),
|
||||||
|
extractXmlValue(xml, 'clientmsgid')
|
||||||
|
].filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetId: targetIds[0] || undefined,
|
||||||
|
targetIds,
|
||||||
|
replacement,
|
||||||
|
actor: actorMatch?.[1]?.trim() || undefined,
|
||||||
|
sessionId: extractXmlValue(xml, 'session') || undefined,
|
||||||
|
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseImageMessage(content: string): ParsedContent {
|
function parseImageMessage(content: string): ParsedContent {
|
||||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||||
@@ -433,7 +523,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractXmlAttribute(xml: string, tagName: string, attrName: 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)
|
const match = xml.match(pattern)
|
||||||
return match ? match[1].trim() : ''
|
return match ? match[1].trim() : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
DatabaseKeyValidationCode,
|
DatabaseKeyValidationCode,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -49,8 +50,11 @@ export interface FormattedMessage {
|
|||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
@@ -163,7 +167,7 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
|||||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMessages(
|
function listSourceMessages(
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
@@ -221,7 +225,7 @@ export function listMessages(
|
|||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
? 49
|
? 49
|
||||||
: msgType
|
: msgType
|
||||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
if ([3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
try {
|
try {
|
||||||
const parsed =
|
const parsed =
|
||||||
inferredMsgType === 47
|
inferredMsgType === 47
|
||||||
@@ -264,8 +268,12 @@ export function listMessages(
|
|||||||
|
|
||||||
if (msgType === 34) content = '[语音消息]'
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
|
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg.mesLocalID || Math.random().toString(),
|
id: recoveredFromRecallJournal
|
||||||
|
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
|
||||||
|
: msg.mesLocalID || Math.random().toString(),
|
||||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||||
isSender: isMine,
|
isSender: isMine,
|
||||||
type: displayType,
|
type: displayType,
|
||||||
@@ -276,7 +284,9 @@ export function listMessages(
|
|||||||
senderId,
|
senderId,
|
||||||
sessionId: username,
|
sessionId: username,
|
||||||
localId,
|
localId,
|
||||||
|
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||||
createTime,
|
createTime,
|
||||||
|
recoveredFromRecallJournal,
|
||||||
contentData
|
contentData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -287,6 +297,19 @@ export function listMessages(
|
|||||||
return formatted
|
return formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMessages(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): FormattedMessage[] {
|
||||||
|
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
|
||||||
|
if (!dbRef) return sourceMessages
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Message } from '../../shared/types'
|
||||||
|
|
||||||
|
type RecallRecord = {
|
||||||
|
targetIds: string[]
|
||||||
|
actor?: string
|
||||||
|
noticeId: string
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchivedMessage = Message & {
|
||||||
|
archivedAt: number
|
||||||
|
recallNotice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionArchive = {
|
||||||
|
username: string
|
||||||
|
updatedAt: number
|
||||||
|
messages: ArchivedMessage[]
|
||||||
|
recalls: RecallRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecallArchiveFile = {
|
||||||
|
version: 1
|
||||||
|
accountRoot: string
|
||||||
|
updatedAt: number
|
||||||
|
sessions: Record<string, SessionArchive>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARCHIVE_VERSION = 1
|
||||||
|
const MAX_MESSAGES_PER_SESSION = 3000
|
||||||
|
const MAX_RECALLS_PER_SESSION = 500
|
||||||
|
const WRITE_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
let archive: RecallArchiveFile | null = null
|
||||||
|
let archivePath = ''
|
||||||
|
let writeTimer: NodeJS.Timeout | null = null
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
function messageIdentity(message: Message): string {
|
||||||
|
if (message.recoveredFromRecallJournal) {
|
||||||
|
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||||
|
}
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
if (message.id) return `id:${message.id}`
|
||||||
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTargetIds(message: Message): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
message.serverId,
|
||||||
|
message.localId ? String(message.localId) : '',
|
||||||
|
message.id ? String(message.id) : ''
|
||||||
|
].filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecallNotice(message: Message): boolean {
|
||||||
|
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRecall(message: Message, recall: RecallRecord): boolean {
|
||||||
|
const messageIds = messageTargetIds(message)
|
||||||
|
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRecallTarget(
|
||||||
|
messages: Iterable<ArchivedMessage>,
|
||||||
|
notice: Message,
|
||||||
|
actor?: string,
|
||||||
|
claimedTargetIds: Set<string> = new Set()
|
||||||
|
): ArchivedMessage | undefined {
|
||||||
|
const noticeTime = notice.createTime || 0
|
||||||
|
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
|
||||||
|
const eligible = Array.from(messages)
|
||||||
|
.filter((message) => {
|
||||||
|
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
|
||||||
|
return false
|
||||||
|
if (!message.recoveredFromRecallJournal) return false
|
||||||
|
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
|
||||||
|
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
|
||||||
|
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
|
||||||
|
if (timeDelta) return timeDelta
|
||||||
|
return (right.localId || 0) - (left.localId || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const strict = eligible.find((message) => {
|
||||||
|
if (expectsMine) return message.isSender
|
||||||
|
if (!actor) return true
|
||||||
|
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
|
||||||
|
return !names.some(Boolean) || names.includes(actor)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Journal rows are captured at deletion time, so the fallback never points at
|
||||||
|
// an unrelated visible message merely because it happened to be nearby.
|
||||||
|
return strict || eligible[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
|
||||||
|
if (isRecallNotice(message)) return message
|
||||||
|
const recall = recalls.find((item) => matchesRecall(message, item))
|
||||||
|
if (!recall) return message
|
||||||
|
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
from: recalledByMe ? 'assistant' : message.from,
|
||||||
|
isSender: recalledByMe ? true : message.isSender,
|
||||||
|
recalled: true,
|
||||||
|
recalledBy: recall.actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWrite(): void {
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
if (writeTimer) clearTimeout(writeTimer)
|
||||||
|
writeTimer = setTimeout(() => {
|
||||||
|
writeTimer = null
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
const targetPath = archivePath
|
||||||
|
const serialized = JSON.stringify(archive)
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
await fs.ensureDir(path.dirname(targetPath))
|
||||||
|
const temporaryPath = `${targetPath}.tmp`
|
||||||
|
await fs.writeFile(temporaryPath, serialized, 'utf8')
|
||||||
|
await fs.move(temporaryPath, targetPath, { overwrite: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[RecallArchive] write failed:', error)
|
||||||
|
})
|
||||||
|
}, WRITE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadArchive(accountRoot: string): RecallArchiveFile {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${process.platform}:${accountRoot}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(archivePath)) {
|
||||||
|
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
|
||||||
|
if (
|
||||||
|
stored.version === ARCHIVE_VERSION &&
|
||||||
|
stored.accountRoot === accountRoot &&
|
||||||
|
stored.sessions &&
|
||||||
|
typeof stored.sessions === 'object'
|
||||||
|
) {
|
||||||
|
return stored as RecallArchiveFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[RecallArchive] read failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: ARCHIVE_VERSION,
|
||||||
|
accountRoot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sessions: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureRecallArchive(accountRoot: string): void {
|
||||||
|
const normalizedRoot = String(accountRoot || '').trim()
|
||||||
|
if (!normalizedRoot) {
|
||||||
|
archive = null
|
||||||
|
archivePath = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (archive?.accountRoot === normalizedRoot) return
|
||||||
|
archive = loadArchive(normalizedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecallArchiveMessages(
|
||||||
|
sessionMd5: string,
|
||||||
|
username: string,
|
||||||
|
messages: Message[]
|
||||||
|
): void {
|
||||||
|
if (!archive || !sessionMd5 || messages.length === 0) return
|
||||||
|
|
||||||
|
const bucket =
|
||||||
|
archive.sessions[sessionMd5] ||
|
||||||
|
({
|
||||||
|
username,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
recalls: []
|
||||||
|
} satisfies SessionArchive)
|
||||||
|
if (username) bucket.username = username
|
||||||
|
|
||||||
|
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
|
||||||
|
let changed = false
|
||||||
|
const targetlessNoticeIds = new Set(
|
||||||
|
messages
|
||||||
|
.filter((message) => {
|
||||||
|
const recall =
|
||||||
|
message.contentData?.type === 'system' ? message.contentData.recall : undefined
|
||||||
|
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
|
||||||
|
})
|
||||||
|
.map(messageIdentity)
|
||||||
|
)
|
||||||
|
if (targetlessNoticeIds.size > 0) {
|
||||||
|
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
|
||||||
|
if (retained.length !== bucket.recalls.length) {
|
||||||
|
bucket.recalls = retained
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceMessage of messages) {
|
||||||
|
const recallData =
|
||||||
|
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
|
||||||
|
if (recallData) {
|
||||||
|
let targetIds = Array.from(
|
||||||
|
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
|
||||||
|
)
|
||||||
|
const noticeId = messageIdentity(sourceMessage)
|
||||||
|
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
|
||||||
|
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
|
||||||
|
const existingRecord =
|
||||||
|
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
|
||||||
|
const existingTargetsNotice =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length > 0 &&
|
||||||
|
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetIds.length === 0 &&
|
||||||
|
existingRecord &&
|
||||||
|
!existingTargetsNotice &&
|
||||||
|
!targetlessNoticeIds.has(noticeId)
|
||||||
|
) {
|
||||||
|
targetIds = existingRecord.targetIds
|
||||||
|
}
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
|
||||||
|
const inferred = inferRecallTarget(
|
||||||
|
byIdentity.values(),
|
||||||
|
sourceMessage,
|
||||||
|
recallData.actor,
|
||||||
|
claimedTargetIds
|
||||||
|
)
|
||||||
|
if (inferred) targetIds = messageTargetIds(inferred)
|
||||||
|
}
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
const nextRecord: RecallRecord = {
|
||||||
|
targetIds,
|
||||||
|
actor: recallData.actor,
|
||||||
|
noticeId,
|
||||||
|
createTime: sourceMessage.createTime || 0
|
||||||
|
}
|
||||||
|
const unchanged =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length === targetIds.length &&
|
||||||
|
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
|
||||||
|
existingRecord.actor === nextRecord.actor
|
||||||
|
if (!unchanged) {
|
||||||
|
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
|
||||||
|
else bucket.recalls.push(nextRecord)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
|
||||||
|
const next: ArchivedMessage = {
|
||||||
|
...annotated,
|
||||||
|
archivedAt: Date.now(),
|
||||||
|
recallNotice: isRecallNotice(sourceMessage)
|
||||||
|
}
|
||||||
|
const identity = messageIdentity(next)
|
||||||
|
const previous = byIdentity.get(identity)
|
||||||
|
if (
|
||||||
|
!previous ||
|
||||||
|
previous.recalled !== next.recalled ||
|
||||||
|
previous.content !== next.content ||
|
||||||
|
previous.serverId !== next.serverId
|
||||||
|
) {
|
||||||
|
byIdentity.set(identity, next)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
|
||||||
|
const nextMessages = Array.from(byIdentity.values())
|
||||||
|
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
.slice(-MAX_MESSAGES_PER_SESSION)
|
||||||
|
|
||||||
|
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
|
||||||
|
|
||||||
|
bucket.messages = nextMessages
|
||||||
|
bucket.recalls = recalls
|
||||||
|
bucket.updatedAt = Date.now()
|
||||||
|
archive.sessions[sessionMd5] = bucket
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRecallArchiveMessages<T extends Message>(
|
||||||
|
sessionMd5: string,
|
||||||
|
sourceMessages: T[],
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): T[] {
|
||||||
|
const bucket = archive?.sessions[sessionMd5]
|
||||||
|
if (!bucket) return sourceMessages
|
||||||
|
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const message of sourceMessages) {
|
||||||
|
const archived = bucket.messages.find(
|
||||||
|
(candidate) => messageIdentity(candidate) === messageIdentity(message)
|
||||||
|
)
|
||||||
|
merged.set(
|
||||||
|
messageIdentity(message),
|
||||||
|
annotateFromRecall(
|
||||||
|
archived?.recalled
|
||||||
|
? { ...message, recalled: true, recalledBy: archived.recalledBy }
|
||||||
|
: message,
|
||||||
|
bucket.recalls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const archived of bucket.messages) {
|
||||||
|
if (!archived.recalled && !archived.recallNotice) continue
|
||||||
|
const createTime = archived.createTime || 0
|
||||||
|
if (startTime && createTime < startTime) continue
|
||||||
|
if (endTime && createTime > endTime) continue
|
||||||
|
const identity = messageIdentity(archived)
|
||||||
|
if (!merged.has(identity)) merged.set(identity, archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array.from(merged.values()).sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
const visible = limit && result.length > limit ? result.slice(-limit) : result
|
||||||
|
return visible as T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChangedConversationMd5(payload: string): string[] {
|
||||||
|
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
matches
|
||||||
|
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
|
||||||
|
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveContact = {
|
||||||
|
md5: string
|
||||||
|
m_nsUsrName: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
activityKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecallArchiveMonitor {
|
||||||
|
private pending = new Set<string>()
|
||||||
|
private activityBySession = new Map<string, string>()
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private stopped = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly listContacts: () => ArchiveContact[],
|
||||||
|
private readonly loadMessages: (sessionMd5: string) => Message[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
seedAll(): void {
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
this.activityBySession = new Map(
|
||||||
|
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
|
||||||
|
)
|
||||||
|
const allowed = new Set(groups.map((contact) => contact.md5))
|
||||||
|
if (archive) {
|
||||||
|
let pruned = false
|
||||||
|
for (const sessionMd5 of Object.keys(archive.sessions)) {
|
||||||
|
if (allowed.has(sessionMd5)) continue
|
||||||
|
delete archive.sessions[sessionMd5]
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
if (pruned) {
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDatabaseChange(payload: string): void {
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
||||||
|
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
||||||
|
const changedActivity = groups
|
||||||
|
.filter((contact) => {
|
||||||
|
const nextKey = String(contact.activityKey || '')
|
||||||
|
const previousKey = this.activityBySession.get(contact.md5)
|
||||||
|
this.activityBySession.set(contact.md5, nextKey)
|
||||||
|
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
|
||||||
|
})
|
||||||
|
.map((contact) => contact.md5)
|
||||||
|
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
|
||||||
|
if (targets.length > 0) {
|
||||||
|
console.log(`[RecallArchive] database change queued=${targets.length}`)
|
||||||
|
this.enqueue(targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private conversationContacts(): ArchiveContact[] {
|
||||||
|
return this.listContacts().filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.m_nsUsrName &&
|
||||||
|
!contact.m_nsUsrName.startsWith('@placeholder') &&
|
||||||
|
contact.m_nsUsrName !== 'brandsessionholder' &&
|
||||||
|
contact.m_nsUsrName !== 'brandservicesessionholder'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true
|
||||||
|
this.pending.clear()
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(sessionMd5s: string[]): void {
|
||||||
|
if (this.stopped) return
|
||||||
|
for (const md5 of sessionMd5s) {
|
||||||
|
if (md5) this.pending.add(md5)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (this.stopped || this.timer || this.pending.size === 0) return
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null
|
||||||
|
const md5 = this.pending.values().next().value as string | undefined
|
||||||
|
if (!md5) return
|
||||||
|
this.pending.delete(md5)
|
||||||
|
try {
|
||||||
|
this.loadMessages(md5)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>()
|
||||||
|
private index: Map<string, VideoAsset> | 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<string, VideoAsset> {
|
||||||
|
if (this.index) return this.index
|
||||||
|
const result = new Map<string, VideoAsset>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
-27
@@ -16,6 +16,7 @@ export interface Wcdb4Session {
|
|||||||
|
|
||||||
export interface Wcdb4Message {
|
export interface Wcdb4Message {
|
||||||
mesLocalID: string
|
mesLocalID: string
|
||||||
|
serverId?: string
|
||||||
mesDes: number
|
mesDes: number
|
||||||
messageType: string
|
messageType: string
|
||||||
msgCreateTime: string
|
msgCreateTime: string
|
||||||
@@ -30,6 +31,11 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Wcdb4MessageStore = {
|
||||||
|
tableName: string
|
||||||
|
dbPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -45,6 +51,11 @@ export interface Wcdb4ImageHardlink {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Wcdb4VideoHardlink {
|
||||||
|
resolved_md5?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiModule = {
|
type KoffiModule = {
|
||||||
load: (libraryPath: string) => KoffiLibrary
|
load: (libraryPath: string) => KoffiLibrary
|
||||||
decode: (ptr: unknown, type: string, length: number) => string
|
decode: (ptr: unknown, type: string, length: number) => string
|
||||||
@@ -217,6 +228,9 @@ export class Wcdb4Client {
|
|||||||
private wcdbResolveImageHardlink:
|
private wcdbResolveImageHardlink:
|
||||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbResolveVideoHardlink:
|
||||||
|
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbGetEmoticonCdnUrl:
|
private wcdbGetEmoticonCdnUrl:
|
||||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
@@ -307,7 +321,9 @@ export class Wcdb4Client {
|
|||||||
candidates.push(...discoverWindowsDbRoots())
|
candidates.push(...discoverWindowsDbRoots())
|
||||||
return Array.from(new Set(candidates))
|
return Array.from(new Set(candidates))
|
||||||
}
|
}
|
||||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
return [
|
||||||
|
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getWeflowDbPathCandidates(home: string): string[] {
|
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||||
@@ -331,7 +347,9 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
private static hasDbStorage(candidate: string): boolean {
|
private static hasDbStorage(candidate: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
return (
|
||||||
|
fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -588,6 +606,11 @@ export class Wcdb4Client {
|
|||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateSessionCache(): void {
|
||||||
|
this.cachedSessions = null
|
||||||
|
this.cachedChatTables = null
|
||||||
|
}
|
||||||
|
|
||||||
getChatTables(): { name: string; db_number: string }[] {
|
getChatTables(): { name: string; db_number: string }[] {
|
||||||
if (this.cachedChatTables) return this.cachedChatTables
|
if (this.cachedChatTables) return this.cachedChatTables
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -616,10 +639,12 @@ export class Wcdb4Client {
|
|||||||
try {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages) {
|
||||||
|
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||||
|
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
`[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
return cursorMessages
|
return mergedMessages
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||||
@@ -648,10 +673,7 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
|
||||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const batch = Array.isArray(rows) ? rows : []
|
const batch = Array.isArray(rows) ? rows : []
|
||||||
@@ -694,17 +716,9 @@ export class Wcdb4Client {
|
|||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: { tableName: string; dbPath: string }[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
tables = this.listMessageStores(username)
|
||||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
|
||||||
)
|
|
||||||
tables = (Array.isArray(rows) ? rows : [])
|
|
||||||
.map((row) => ({
|
|
||||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
|
||||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
|
||||||
}))
|
|
||||||
.filter((row) => row.tableName && row.dbPath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||||
return []
|
return []
|
||||||
@@ -739,6 +753,172 @@ export class Wcdb4Client {
|
|||||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||||
|
const stores = new Map<string, Wcdb4MessageStore>()
|
||||||
|
for (const username of this.uniq(usernames)) {
|
||||||
|
try {
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const store of stores.values()) {
|
||||||
|
try {
|
||||||
|
const columns = this.readMessageColumns(store)
|
||||||
|
if (columns.length === 0) throw new Error('消息表没有可归档列')
|
||||||
|
this.ensureRecallJournalTable(store, columns)
|
||||||
|
this.createRecallJournalTrigger(store, columns)
|
||||||
|
installed += 1
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1
|
||||||
|
console.warn(
|
||||||
|
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed, failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
||||||
|
if (!this.wcdbGetMessageTableStats) return []
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||||
|
)
|
||||||
|
return (Array.isArray(rows) ? rows : [])
|
||||||
|
.map((row) => ({
|
||||||
|
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||||
|
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||||
|
}))
|
||||||
|
.filter((row) => row.tableName && row.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
|
||||||
|
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
|
||||||
|
)
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const name = this.pickString(row, ['name'])
|
||||||
|
const type = this.pickString(row, ['type']).toUpperCase()
|
||||||
|
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
|
||||||
|
return { name, declaration }
|
||||||
|
})
|
||||||
|
.filter((column) => column.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRecallJournalTable(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const definitions = columns
|
||||||
|
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
|
||||||
|
.join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRecallJournalTrigger(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const table = this.quoteSqlIdentifier(store.tableName)
|
||||||
|
const triggerName = this.quoteSqlIdentifier(
|
||||||
|
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
|
||||||
|
)
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const targetColumns = [
|
||||||
|
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
|
||||||
|
'"_wxe_source_table"',
|
||||||
|
'"_wxe_captured_at"'
|
||||||
|
].join(', ')
|
||||||
|
const sourceValues = [
|
||||||
|
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
|
||||||
|
`'${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
`CAST(strftime('%s', 'now') AS INTEGER)`
|
||||||
|
].join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRecallJournal(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const recoveredRows: Record<string, unknown>[] = []
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
try {
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const where = [
|
||||||
|
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||||
|
end > 0 ? `"create_time" <= ${end}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
|
||||||
|
)
|
||||||
|
recoveredRows.push(...rows)
|
||||||
|
} catch {
|
||||||
|
// The journal is optional until installation has completed for this store.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeMessageRows(
|
||||||
|
current: Wcdb4Message[],
|
||||||
|
recovered: Wcdb4Message[],
|
||||||
|
limit?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const merged = new Map<string, Wcdb4Message>()
|
||||||
|
for (const message of [...recovered, ...current]) {
|
||||||
|
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||||
|
const identity = message.mesLocalID
|
||||||
|
? `local:${message.mesLocalID}`
|
||||||
|
: message.serverId
|
||||||
|
? `server:${message.serverId}`
|
||||||
|
: `${message.msgCreateTime}:${message.msgContent}`
|
||||||
|
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||||
|
merged.set(key, message)
|
||||||
|
}
|
||||||
|
const messages = Array.from(merged.values()).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||||
|
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||||
|
)
|
||||||
|
return limit && messages.length > limit ? messages.slice(-limit) : messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private recallJournalTableName(messageTableName: string): string {
|
||||||
|
return `_wxe_recall_journal_${crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(messageTableName)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
getMyAvatarUrl(): string | undefined {
|
getMyAvatarUrl(): string | undefined {
|
||||||
const candidates = this.getMyUsernameCandidates()
|
const candidates = this.getMyUsernameCandidates()
|
||||||
this.hydrateAvatarUrls(candidates)
|
this.hydrateAvatarUrls(candidates)
|
||||||
@@ -856,8 +1036,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||||
|
|
||||||
return visibleMessages
|
return visibleMessages.map((message) => {
|
||||||
.map((message) => {
|
|
||||||
if (!message.sender) return message
|
if (!message.sender) return message
|
||||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||||
@@ -915,11 +1094,7 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'contact_remark'
|
||||||
])
|
])
|
||||||
const memberNickname = this.pickString(row, [
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
'displayName',
|
|
||||||
'display_name',
|
|
||||||
'name'
|
|
||||||
])
|
|
||||||
const avatar = this.pickString(row, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -933,8 +1108,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname:
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
|
||||||
groupNickname: groupNicknames.get(username) || '',
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
wechatNickname: wechatNickname || memberNickname,
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
remark,
|
remark,
|
||||||
@@ -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<Wcdb4VideoHardlink>((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 {
|
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
||||||
if (!this.wcdbGetEmoticonCdnUrl) {
|
if (!this.wcdbGetEmoticonCdnUrl) {
|
||||||
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
||||||
@@ -1286,6 +1477,14 @@ export class Wcdb4Client {
|
|||||||
this.wcdbResolveImageHardlink = null
|
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 {
|
try {
|
||||||
this.wcdbGetEmoticonCdnUrl = lib.func(
|
this.wcdbGetEmoticonCdnUrl = lib.func(
|
||||||
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
'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',
|
'mesLocalID',
|
||||||
'id'
|
'id'
|
||||||
])
|
])
|
||||||
|
const serverId = this.pickString(row, [
|
||||||
|
'server_id',
|
||||||
|
'serverId',
|
||||||
|
'svr_id',
|
||||||
|
'svrId',
|
||||||
|
'msg_svr_id',
|
||||||
|
'msgSvrId',
|
||||||
|
'message_id',
|
||||||
|
'messageId',
|
||||||
|
'new_msg_id',
|
||||||
|
'newMsgId',
|
||||||
|
'WCDB_CT_server_id'
|
||||||
|
])
|
||||||
const messageType = this.pickString(row, [
|
const messageType = this.pickString(row, [
|
||||||
'local_type',
|
'local_type',
|
||||||
'localType',
|
'localType',
|
||||||
@@ -1541,6 +1753,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||||
|
serverId: serverId || undefined,
|
||||||
mesDes: isSend ? 0 : 1,
|
mesDes: isSend ? 0 : 1,
|
||||||
messageType: messageType || '1',
|
messageType: messageType || '1',
|
||||||
msgCreateTime: String(createTime),
|
msgCreateTime: String(createTime),
|
||||||
|
|||||||
Vendored
+12
@@ -48,6 +48,15 @@ export type ParsedContent =
|
|||||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: 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'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -151,6 +160,9 @@ declare global {
|
|||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}>
|
}>
|
||||||
|
getVideo: (
|
||||||
|
hashes: string[]
|
||||||
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const api = {
|
|||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean }
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => 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),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
ipcRenderer.invoke('report:export', request),
|
ipcRenderer.invoke('report:export', request),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import ChatWindow from './components/ChatWindow'
|
import ChatWindow from './components/ChatWindow'
|
||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
|
|||||||
@@ -1296,6 +1296,15 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-recalled-status {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #c63c32;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-hover-time {
|
.message-hover-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -1366,6 +1375,54 @@ body {
|
|||||||
display: none;
|
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 {
|
.image-bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: min(260px, 46vw);
|
max-width: min(260px, 46vw);
|
||||||
|
|||||||
@@ -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 <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-message">
|
||||||
|
<video
|
||||||
|
className="video-content"
|
||||||
|
src={media.url}
|
||||||
|
poster={media.poster}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
{duration ? <span className="video-duration">{duration} 秒</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
|
|||||||
import { ImageBubble } from '../ImageBubble'
|
import { ImageBubble } from '../ImageBubble'
|
||||||
import { RichMessageBubble } from '../RichMessageBubble'
|
import { RichMessageBubble } from '../RichMessageBubble'
|
||||||
import { VoicePlayer } from '../VoicePlayer'
|
import { VoicePlayer } from '../VoicePlayer'
|
||||||
|
import { VideoBubble } from '../VideoBubble'
|
||||||
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||||
import { formatMessageTime } from './messageGrouping'
|
import { formatMessageTime } from './messageGrouping'
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function MessageBubble({
|
|||||||
}: MessageBubbleProps): React.ReactElement {
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
const isVoice = message.type === '语音'
|
const isVoice = message.type === '语音'
|
||||||
const isImage = message.type === '图片'
|
const isImage = message.type === '图片'
|
||||||
|
const isVideo = message.type === '视频'
|
||||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||||
const hoverTime = formatMessageTime(message)
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ export function MessageBubble({
|
|||||||
<div
|
<div
|
||||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||||
isImage ? 'image-message-bubble' : ''
|
isImage ? 'image-message-bubble' : ''
|
||||||
}`}
|
} ${isVideo ? 'video-message-bubble' : ''}`}
|
||||||
>
|
>
|
||||||
{isVoice && message.sessionId ? (
|
{isVoice && message.sessionId ? (
|
||||||
<VoicePlayer
|
<VoicePlayer
|
||||||
@@ -50,12 +52,24 @@ export function MessageBubble({
|
|||||||
sessionId={message.sessionId}
|
sessionId={message.sessionId}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
/>
|
/>
|
||||||
|
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
|
||||||
|
<VideoBubble
|
||||||
|
md5={message.contentData.md5}
|
||||||
|
newMd5={message.contentData.newMd5}
|
||||||
|
rawMd5={message.contentData.rawMd5}
|
||||||
|
duration={message.contentData.duration}
|
||||||
|
/>
|
||||||
) : isRichMedia && message.contentData ? (
|
) : isRichMedia && message.contentData ? (
|
||||||
<RichMessageBubble contentData={message.contentData} />
|
<RichMessageBubble contentData={message.contentData} />
|
||||||
) : (
|
) : (
|
||||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{message.recalled && (
|
||||||
|
<span className="message-recalled-status" title={message.recalledBy || undefined}>
|
||||||
|
消息已撤回
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="message-hover-time">{hoverTime}</span>
|
<span className="message-hover-time">{hoverTime}</span>
|
||||||
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||||
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||||
|
|||||||
+27
-1
@@ -20,8 +20,12 @@ export interface Message {
|
|||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
|
recoveredFromRecallJournal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextContent = { type: 'text'; content: string }
|
type TextContent = { type: 'text'; content: string }
|
||||||
@@ -50,6 +54,15 @@ type ImageContent = {
|
|||||||
aeskey?: string
|
aeskey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
}
|
}
|
||||||
|
type VideoContent = {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
type StickerContent = {
|
type StickerContent = {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -67,7 +80,19 @@ type QuoteContent = {
|
|||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: string
|
quotedType?: string
|
||||||
}
|
}
|
||||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
type SystemContent = {
|
||||||
|
type: 'system'
|
||||||
|
content: string
|
||||||
|
raw?: string
|
||||||
|
recall?: {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
@@ -78,6 +103,7 @@ export type ParsedContent =
|
|||||||
| ShareContent
|
| ShareContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
|
| VideoContent
|
||||||
| StickerContent
|
| StickerContent
|
||||||
| QuoteContent
|
| QuoteContent
|
||||||
| SystemContent
|
| SystemContent
|
||||||
|
|||||||
Reference in New Issue
Block a user