feat: 支持微信 4.0 数据库解密与富媒体消息查看

- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式
- 支持图片解密及图片预览、缩放、旋转和拖动
- 支持语音解密与播放
- 支持表情包、引用、分享、名片、位置及通话消息解析
- 支持联系人和群聊真实头像
- 优化群聊、联系人分类及排序
- 修复复合消息类型和压缩消息内容解析
- 完善原生解密库打包及数据库错误提示
This commit is contained in:
电摇小子
2026-06-29 14:51:45 +08:00
parent 7196adb84c
commit f6f8bc33f9
29 changed files with 4410 additions and 122 deletions
+162
View File
@@ -0,0 +1,162 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { Wcdb4Client } from './wcdb4-client'
export class VoiceService {
private wcdb4Client: Wcdb4Client
private voiceCache = new Map<string, string>()
constructor(wcdb4Client: Wcdb4Client) {
this.wcdb4Client = wcdb4Client
}
async resolveVoice(
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
): Promise<{ success: boolean; data?: string; error?: string }> {
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
const cached = this.voiceCache.get(cacheKey)
if (cached) {
console.log('[VoiceService] cache hit for', cacheKey)
return { success: true, data: cached }
}
const candidates = this.buildCandidates(sessionId)
console.log('[VoiceService] resolving voice:', { sessionId, localId, createTime, candidates })
const voiceResult = await this.wcdb4Client.getVoiceData(
sessionId,
createTime,
candidates,
localId,
svrId || 0
)
if (!voiceResult.success || !voiceResult.hex) {
console.log('[VoiceService] getVoiceData failed:', voiceResult.error)
return { success: false, error: voiceResult.error || '获取语音数据失败' }
}
console.log('[VoiceService] got hex data, length:', voiceResult.hex.length)
const silkData = this.decodeVoiceBlob(voiceResult.hex)
if (!silkData || silkData.length === 0) {
console.log('[VoiceService] decodeVoiceBlob failed, hex:', voiceResult.hex.substring(0, 100))
return { success: false, error: '语音数据为空' }
}
console.log('[VoiceService] silkData length:', silkData.length)
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
if (!pcmData || pcmData.length === 0) {
console.log('[VoiceService] decodeSilkToPcm failed')
return { success: false, error: 'Silk 解码失败' }
}
console.log('[VoiceService] pcmData length:', pcmData.length)
const wavData = this.createWavBuffer(pcmData, 24000)
console.log(
'[VoiceService] wavData length:',
wavData.length,
'base64 length:',
wavData.toString('base64').length
)
const base64Data = wavData.toString('base64')
this.voiceCache.set(cacheKey, base64Data)
return { success: true, data: base64Data }
}
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
return `${sessionId}-${localId}-${createTime}`
}
private buildCandidates(sessionId: string): string[] {
const candidates: string[] = [sessionId]
if (sessionId.endsWith('@chatroom')) {
candidates.push(sessionId.replace('@chatroom', ''))
}
return candidates
}
private decodeVoiceBlob(hex: string): Buffer | null {
try {
const hexClean = hex.replace(/\s+/g, '')
if (!/^[0-9a-fA-F]+$/.test(hexClean)) {
return null
}
return Buffer.from(hexClean, 'hex')
} catch {
return null
}
}
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
wasmPath = join(
process.resourcesPath,
'app.asar.unpacked',
'node_modules',
'silk-wasm',
'lib',
'silk.wasm'
)
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
console.error('[VoiceService] silk.wasm not found at:', wasmPath)
return null
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const silkWasm = require('silk-wasm')
if (!silkWasm || !silkWasm.decode) {
console.error('[VoiceService] silk-wasm module invalid')
return null
}
const result = await silkWasm.decode(silkData, sampleRate)
return Buffer.from(result.data)
} catch (e) {
console.error('[VoiceService] decodeSilkToPcm error:', e)
return null
}
}
private createWavBuffer(
pcmData: Buffer,
sampleRate: number = 24000,
channels: number = 1
): Buffer {
const pcmLength = pcmData.length
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcmLength, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20)
header.writeUInt16LE(channels, 22)
header.writeUInt32LE(sampleRate, 24)
header.writeUInt32LE(sampleRate * channels * 2, 28)
header.writeUInt16LE(channels * 2, 32)
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcmLength, 40)
return Buffer.concat([header, pcmData])
}
}