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
+528
View File
@@ -0,0 +1,528 @@
import { basename, dirname, extname, join } from 'path'
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
import crypto from 'crypto'
import os from 'os'
import { Wcdb4Client } from './wcdb4-client'
export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
// 解析 XOR Key (支持 0x40 或 64 格式)
const xorHex = xorKey.trim().toLowerCase()
if (xorHex.startsWith('0x')) {
this.xorKey = parseInt(xorHex, 16)
} else {
this.xorKey = parseInt(xorHex, 10)
}
// AES Key 直接使用
this.aesKey = aesKey.trim()
this.wcdb4Client = wcdb4Client || null
}
/**
* 获取账号目录
*/
private getAccountDir(): string | null {
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
return wcdbAccountRoot
}
const homeDir = os.homedir()
const accountRoot = join(
homeDir,
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
)
if (!existsSync(accountRoot)) {
console.log('[ImageDecrypt] account root not found:', accountRoot)
return null
}
const accounts = readdirSync(accountRoot)
.filter((name) => {
const fullPath = join(accountRoot, name)
try {
return statSync(fullPath).isDirectory()
} catch {
return false
}
})
.map((name) => ({
name,
mtime: statSync(join(accountRoot, name)).mtimeMs
}))
.sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) {
console.log('[ImageDecrypt] no accounts found')
return null
}
// 返回最新的账号目录
return join(accountRoot, accounts[0].name)
}
/**
* 根据 md5 查找图片文件 (WechatExplorer 风格)
*/
findImageFile(md5?: string, imageDatName?: string): string | null {
const accountDir = this.getAccountDir()
if (!accountDir) return null
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
console.log('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir
})
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (fullPath && existsSync(fullPath)) {
console.log('[ImageDecrypt] hardlink hit:', fullPath)
return this.getPreferredDatVariantPath(fullPath, true)
}
}
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) {
console.log('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
}
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
if (searchKeys.length === 0) return null
for (const key of searchKeys) {
const directHit = this.fastProbabilisticSearch(attachDir, key)
if (directHit) return directHit
}
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0])
if (legacyHit) return legacyHit
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
private fastProbabilisticSearch(attachDir: string, datName: string): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const variants = this.buildPreferredDatNames(normalized)
if (/^[a-f0-9]{32}$/.test(normalized)) {
const dir1 = normalized.substring(0, 2)
const dir2 = normalized.substring(2, 4)
for (const variant of variants) {
const candidates = [
join(attachDir, dir1, dir2, variant),
join(attachDir, dir1, dir2, 'Img', variant),
join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant)
]
const found = candidates.find((candidate) => existsSync(candidate))
if (found) {
console.log('[ImageDecrypt] prefix path hit:', found)
return found
}
}
}
try {
const sessionDirs = readdirSync(attachDir).filter(
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
)
const now = new Date()
const months: string[] = []
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
for (const sessDir of sessionDirs) {
for (const month of months) {
for (const sub of ['Img', 'Image', 'image']) {
const imgDir = join(attachDir, sessDir, month, sub)
if (!existsSync(imgDir)) continue
const found = variants
.map((variant) => join(imgDir, variant))
.find((candidate) => existsSync(candidate))
if (found) {
console.log('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e)
}
return null
}
private findImageFileInLegacyDirs(accountDir: string, datName: string): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
const roots = [
join(accountDir, 'FileStorage', 'Image'),
join(accountDir, 'FileStorage', 'Image2'),
join(accountDir, 'FileStorage', 'MsgImg')
].filter((root) => existsSync(root))
for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5)
if (found) return found
}
return null
}
private recursiveFindDat(dir: string, datName: string, depth: number): string | null {
if (depth < 0) return null
try {
const variants = new Set(this.buildPreferredDatNames(datName))
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) {
console.log('[ImageDecrypt] legacy path hit:', fullPath)
return fullPath
}
}
for (const entry of entries) {
const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(fullPath, datName, depth - 1)
if (found) return found
}
} catch {
return null
}
return null
}
/**
* 解密图片文件并返回 Buffer
*/
decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) {
console.log('[ImageDecrypt] file not found:', datPath)
return null
}
try {
const version = this.getDatVersion(datPath)
console.log(
'[ImageDecrypt] dat version:',
version,
'file:',
datPath,
'xorKey:',
this.xorKey,
'aesKey present:',
!!this.aesKey
)
let decrypted: Buffer
if (version === 0) {
console.log('[ImageDecrypt] using V3 (XOR only)')
decrypted = this.decryptDatV3(datPath)
} else if (version === 1) {
console.log('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key)
} else {
// version === 2
console.log('[ImageDecrypt] using V2 (user AES key)')
if (!this.aesKey) {
console.log('[ImageDecrypt] no AES key configured')
return null
}
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length)
decrypted = this.decryptDatV4(datPath, key)
}
return decrypted
} catch (error) {
console.error('[ImageDecrypt] decrypt error:', error)
return null
}
}
/**
* 将解密后的图片转换为 base64
*/
decryptImageToBase64(datPath: string): string | null {
if (!extname(datPath).toLowerCase().includes('dat')) {
const data = readFileSync(datPath)
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
const mimeType = this.getMimeType(ext)
return `data:${mimeType};base64,${data.toString('base64')}`
}
const decrypted = this.decryptImage(datPath)
if (!decrypted) return null
const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped)
if (!ext) {
console.log('[ImageDecrypt] unknown image format')
return null
}
const mimeType = this.getMimeType(ext)
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
}
/**
* 检测 DAT 文件版本
*/
private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath)
if (bytes.length < 6) {
return 0
}
const signature = bytes.subarray(0, 6)
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
return 1
}
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
return 2
}
return 0
}
/**
* V3 解密 - 仅 XOR
*/
private decryptDatV3(inputPath: string): Buffer {
const data = readFileSync(inputPath)
const out = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i += 1) {
out[i] = data[i] ^ this.xorKey
}
return out
}
/**
* V4 解密 - AES + XOR
*/
private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer {
const bytes = readFileSync(inputPath)
if (bytes.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
// 对齐 AES 数据到 16 字节边界
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
// 解密 AES 数据
const aesData = data.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted)
}
// 解密 XOR 数据
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData: Buffer
let xoredData: Buffer
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
xoredData[i] = xorData[i] ^ this.xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
/**
* 检测图片扩展名
*/
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 4) return null
const SIGNATURES: Record<string, Buffer> = {
'.jpg': Buffer.from([0xff, 0xd8, 0xff]),
'.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
'.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
'.bmp': Buffer.from([0x42, 0x4d]),
'.webp': Buffer.from([0x52, 0x49, 0x46, 0x46])
}
for (const [ext, sig] of Object.entries(SIGNATURES)) {
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
return ext
}
}
return null
}
private getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp'
}
return mimeTypes[ext] || 'image/jpeg'
}
private normalizeDatBase(value: string): string {
const lower = String(value || '')
.trim()
.toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop()?.split('\\').pop() || lower
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
}
private buildPreferredDatNames(baseName: string): string[] {
const base = this.normalizeDatBase(baseName)
if (!base) return []
return [
`${base}_h.dat`,
`${base}.dat`,
`${base}_hd.dat`,
`${base}_c.dat`,
`${base}_t.dat`,
`${base}.thumb.dat`,
`${base}_thumb.dat`
]
}
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
const ordered = allowThumbnail
? variants
: variants.filter((name) => !this.isThumbnailName(name))
for (const variant of ordered) {
const candidate = join(actualDir, variant)
if (existsSync(candidate)) return candidate
}
return inputPath
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
}
private unwrapWxgf(buffer: Buffer): Buffer {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer
}
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return buffer.subarray(i)
}
if (
buffer[i] === 0x89 &&
buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e &&
buffer[i + 3] === 0x47
) {
return buffer.subarray(i)
}
}
return buffer
}
private uniq(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
private bytesToInt32(bytes: Buffer): number {
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
private compareBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
private strictRemovePadding(buffer: Buffer): Buffer {
if (buffer.length === 0) return buffer
const lastByte = buffer[buffer.length - 1]
if (lastByte <= 16 && lastByte > 0) {
const paddingLength = lastByte
let valid = true
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
if (buffer[i] !== lastByte) {
valid = false
break
}
}
if (valid) {
return buffer.subarray(0, buffer.length - paddingLength)
}
}
return buffer
}
}
+176 -8
View File
@@ -3,20 +3,53 @@ import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb, Contact, WechatMessage } from './wechat-db'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from './message-parser'
import { ImageDecryptService } from './image-decrypt-service'
let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
// In dev, matching the host app name avoids failing the native protection gate.
app.setName('WechatExplorer')
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
function createWindow(): void {
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
@@ -52,6 +85,7 @@ function createWindow(): void {
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(() => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron')
@@ -67,11 +101,21 @@ app.whenReady().then(() => {
ipcMain.handle('db:init', (_, key: string) => {
try {
const trimmedKey = String(key || '').trim()
console.log(
`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}`
)
wechatDb = new WechatDb(key)
return true
const wcdb4Client = wechatDb.getWcdb4Client()
if (wcdb4Client) {
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
}
imageDecryptService = null
return { success: true }
} catch (error) {
console.error('Failed to init DB:', error)
return false
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -86,12 +130,14 @@ app.whenReady().then(() => {
// 1. 处理普通联系人
for (const user of userList) {
const md5 = wechatDb.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5: md5,
type: 'user'
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
@@ -124,17 +170,31 @@ app.whenReady().then(() => {
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
if (!wechatDb) return []
const wcdb4Client = wechatDb.getWcdb4Client()
const username = wcdb4Client?.getUsernameByMd5(userMd5)
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getAllGroupMembers()
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
const myAvatar = wechatDb.getMyAvatarUrl()
return rawMessages.map((msg: WechatMessage) => {
const msgType = parseInt(msg.messageType)
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
if (isMine && myAvatar) {
img = myAvatar
} else if (typeof msg.senderAvatar === 'string') {
img = msg.senderAvatar
}
if (typeof msg.senderNickname === 'string') {
name = msg.senderNickname
}
// 检查内容是否以 wxid 开头并包含冒号
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
if (content && typeof content === 'string') {
@@ -159,14 +219,72 @@ app.whenReady().then(() => {
}
}
// 解析富媒体消息内容
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5 && wcdb4Client) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5 && wcdb4Client) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) {
content = '[语音消息]'
}
return {
id: msg.mesLocalID || Math.random().toString(),
from: msg.mesDes === 1 ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
type: MSG_TYPE_DICT[msgType] || msg.messageType,
from: isMine ? 'assistant' : 'user',
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
img: img,
name: name
name: name,
sessionId: username,
localId: localId,
createTime: createTime,
contentData: contentData
}
})
})
@@ -225,6 +343,56 @@ app.whenReady().then(() => {
}
})
ipcMain.handle(
'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
if (!voiceService) {
return { success: false, error: 'VoiceService 未初始化' }
}
return voiceService.resolveVoice(sessionId, localId, createTime, svrId)
}
)
ipcMain.handle('db:parseMessage', async (_, content: string, messageType: number) => {
return parseMessageContent(content, messageType)
})
ipcMain.handle(
'db:getImage',
async (_, imageMd5?: string, imageDatNameOrThumb?: string | boolean, _sessionId?: string) => {
void _sessionId
if (!imageDecryptService) {
// 从环境变量获取密钥
const xorKey = import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'
const aesKey = import.meta.env.VITE_IMAGE_AES_KEY || ''
if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' }
}
imageDecryptService = new ImageDecryptService(xorKey, aesKey, wechatDb?.getWcdb4Client())
}
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const filePath = imageDecryptService.findImageFile(imageMd5, imageDatName)
if (!filePath) {
return { success: false, error: '未找到图片文件' }
}
const base64 = imageDecryptService.decryptImageToBase64(filePath)
if (!base64) {
return { success: false, error: '图片解密失败' }
}
return { success: true, data: base64 }
}
)
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) {
stickerService = new StickerService(wechatDb?.getWcdb4Client())
}
return stickerService.resolveSticker(cdnUrl, md5)
})
createWindow()
app.on('activate', function () {
+508
View File
@@ -0,0 +1,508 @@
type TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = {
type: 'location'
poiname?: string
label?: string
lat: number
lng: number
}
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
type ShareContent = {
type: 'share'
title: string
des?: string
url: string
appname?: string
typeVal?: string
}
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = {
type: 'image'
md5?: string
datName?: string
aeskey?: string
encrypVer?: number
}
type StickerContent = {
type: 'sticker'
md5?: string
url?: string
thumbUrl?: string
encryptUrl?: string
aeskey?: string
}
type QuoteContent = {
type: 'quote'
title?: string
content?: string
sender?: string
quotedContent?: string
quotedSender?: string
quotedType?: string
}
type SystemContent = { type: 'system'; content: string }
type UnknownContent = { type: 'unknown'; raw: string }
export type ParsedContent =
| TextContent
| VoiceContent
| LocationContent
| CardContent
| ShareContent
| VoipContent
| ImageContent
| StickerContent
| QuoteContent
| SystemContent
| UnknownContent
export function parseMessageContent(content: string, messageType: number): ParsedContent {
if (!content || typeof content !== 'string') {
return { type: 'unknown', raw: content || '' }
}
const normalized = content.trim()
switch (messageType) {
case 3:
return parseImageMessage(normalized)
case 42:
return parseCardMessage(normalized)
case 47:
return parseStickerMessage(normalized)
case 48:
return parseLocationMessage(normalized)
case 49:
return parseShareMessage(normalized)
case 50:
return parseVoipMessage(normalized)
case 10000:
case 10002:
return { type: 'system', content: normalized }
default:
return { type: 'text', content: normalized }
}
}
function parseImageMessage(content: string): ParsedContent {
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
let aeskey =
extractXmlAttribute(content, 'img', 'aeskey') || extractXmlValue(content, 'aeskey') || undefined
const encrypVerStr =
extractXmlAttribute(content, 'img', 'encrypver') || extractXmlValue(content, 'encrypver') || '0'
let datName = ''
// 如果 XML 格式解析失败,尝试 JSON 格式
if (!md5) {
try {
const json = JSON.parse(content)
// 可能是引用消息格式 { type: "...", content: "md5", ... }
if (
json.content &&
typeof json.content === 'string' &&
/^[a-f0-9]{32}$/i.test(json.content)
) {
md5 = json.content
} else if (json.md5 && typeof json.md5 === 'string') {
md5 = json.md5
}
if (json.datName && typeof json.datName === 'string') {
datName = json.datName
}
if (json.imageDatName && typeof json.imageDatName === 'string') {
datName = json.imageDatName
}
// 尝试从其他字段获取 aeskey
if (!aeskey && json.aeskey) {
aeskey = json.aeskey
}
if (!aeskey && json.aeskey_v2) {
aeskey = json.aeskey_v2
}
} catch {
// 不是 JSON 格式
}
}
const encrypVer = parseInt(encrypVerStr, 10)
if (!md5 && !datName) {
return { type: 'unknown', raw: content }
}
return { type: 'image', md5: md5 || undefined, datName: datName || undefined, aeskey, encrypVer }
}
function parseStickerMessage(content: string): ParsedContent {
// 表情包消息可能包含 md5 或 url
const md5 =
extractXmlAttribute(content, 'emoji', 'md5') ||
extractXmlValue(content, 'md5') ||
extractXmlAttribute(content, 'sticker', 'md5') ||
extractLooseHexMd5(content) ||
''
const url = decodeXmlUrl(
extractXmlValue(content, 'url') ||
extractXmlAttribute(content, 'emoji', 'cdnurl') ||
extractXmlAttribute(content, 'emoji', 'url') ||
extractXmlAttribute(content, 'emoji', 'thumburl') ||
extractLooseAttribute(content, 'cdnurl') ||
extractLooseAttribute(content, 'url') ||
extractLooseAttribute(content, 'thumburl') ||
''
)
const thumbUrl = decodeXmlUrl(
extractXmlAttribute(content, 'emoji', 'thumburl') || extractLooseAttribute(content, 'thumburl')
)
const encryptUrl = decodeXmlUrl(
extractXmlAttribute(content, 'emoji', 'encrypturl') ||
extractLooseAttribute(content, 'encrypturl')
)
const aeskey =
extractXmlAttribute(content, 'emoji', 'aeskey') ||
extractLooseAttribute(content, 'aeskey') ||
undefined
if (!md5 && !url && !thumbUrl && !encryptUrl) {
return { type: 'unknown', raw: content }
}
return {
type: 'sticker',
md5,
url: url || thumbUrl || undefined,
thumbUrl: thumbUrl || undefined,
encryptUrl: encryptUrl || undefined,
aeskey
}
}
export function parseStickerMessageFromRow(
row: Record<string, unknown>,
content: string
): ParsedContent {
const supplementalPayload = [
content,
pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']),
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']),
decodeSupplementalPayload(
pickRowString(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info'
])
),
decodeSupplementalPayload(pickRowString(row, ['reserved0', 'Reserved0', 'WCDB_CT_reserved0']))
]
.filter(Boolean)
.join('\n')
const directMd5 = normalizeMd5(pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']))
const directUrl = decodeXmlUrl(
String(
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']) || ''
)
)
const parsed = parseStickerMessage(supplementalPayload)
if (parsed.type === 'sticker') {
return {
...parsed,
md5: parsed.md5 || directMd5,
url: parsed.url || directUrl || undefined
}
}
if (directMd5 || directUrl) {
return {
type: 'sticker',
md5: directMd5,
url: directUrl || undefined
}
}
return parsed
}
function parseCardMessage(content: string): ParsedContent {
const username =
extractXmlValue(content, 'username') || extractXmlValue(content, 'cardUsername') || ''
const nickname =
extractXmlValue(content, 'nickname') || extractXmlValue(content, 'cardNickname') || ''
const avatarUrl =
extractXmlValue(content, 'avatarUrl') ||
extractXmlValue(content, 'smallHeadImgUrl') ||
undefined
if (!username && !nickname) {
return { type: 'unknown', raw: content }
}
return { type: 'card', username, nickname, avatarUrl }
}
function parseLocationMessage(content: string): ParsedContent {
const poiname = extractXmlValue(content, 'poiname') || extractXmlValue(content, 'poiName') || ''
const label = extractXmlValue(content, 'label') || ''
const latStr =
extractXmlAttribute(content, 'location', 'x') ||
extractXmlAttribute(content, 'location', 'latitude') ||
'0'
const lngStr =
extractXmlAttribute(content, 'location', 'y') ||
extractXmlAttribute(content, 'location', 'longitude') ||
'0'
const lat = parseFloat(latStr)
const lng = parseFloat(lngStr)
if (!poiname && lat === 0 && lng === 0) {
return { type: 'unknown', raw: content }
}
return { type: 'location', poiname, label, lat, lng }
}
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
if (appMsgType === '57' || content.includes('<refermsg>')) {
const quote = parseQuoteMessage(content)
const title = extractXmlValue(content, 'title') || undefined
return {
type: 'quote',
title,
content: title,
quotedContent: quote.content || '[引用消息]',
quotedSender: quote.sender,
quotedType: quote.type
}
}
const title = extractXmlValue(content, 'title') || ''
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
const url = extractXmlValue(content, 'url') || ''
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
const typeVal = extractXmlValue(content, 'type') || ''
if (!title && !url) {
return { type: 'unknown', raw: content }
}
return { type: 'share', title, des, url, appname, typeVal }
}
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) return {}
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
const sender =
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
undefined
const referContent = extractXmlValue(referMsgXml, 'content')
const referType = extractXmlValue(referMsgXml, 'type')
switch (referType) {
case '1':
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
case '3':
return { sender, content: '[图片]', type: referType }
case '34':
return { sender, content: '[语音]', type: referType }
case '43':
return { sender, content: '[视频]', type: referType }
case '47':
return { sender, content: '[表情]', type: referType }
case '49':
return {
sender,
content: extractXmlValue(referMsgXml, 'title') || '[分享消息]',
type: referType
}
default:
return {
sender,
content: sanitizeQuotedContent(referContent) || '[引用消息]',
type: referType
}
}
}
function extractAppMsgType(content: string): string {
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (!appmsgMatch) return extractXmlValue(content, 'type')
const inner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
return typeMatch?.[1]?.trim() || ''
}
function sanitizeQuotedContent(content: string): string {
const decoded = String(content || '')
.replace(/^wxid_[^:\n]+:\s*/i, '')
.trim()
if (/^(wxid_[\w-]+|[a-z][a-z0-9_-]{5,})$/i.test(decoded)) return ''
return decoded
}
function parseVoipMessage(content: string): ParsedContent {
const roomTypeStr = extractXmlValue(content, 'room_type')
const msg = extractXmlValue(content, 'msg') || ''
const durationStr = extractXmlValue(content, 'duration') || '0'
const roomType = roomTypeStr ? parseInt(roomTypeStr, 10) : 0
const duration = parseInt(durationStr, 10)
let status = msg
if (!status) {
status = roomType === 1 ? '[视频通话]' : '[语音通话]'
}
return { type: 'voip', duration, status, roomType }
}
function extractXmlValue(xml: string, tagName: string): string {
const patterns = [
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i'),
new RegExp(`${tagName}=["']([^"']*)["']`, 'i')
]
for (const pattern of patterns) {
const match = xml.match(pattern)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
const match = xml.match(pattern)
return match ? match[1].trim() : ''
}
function extractLooseAttribute(content: string, attrName: string): string {
const quoted = new RegExp(`${attrName}\\s*=\\s*["']([^"']+)["']`, 'i').exec(content)
if (quoted?.[1]) return quoted[1].trim()
const unquoted = new RegExp(`${attrName}\\s*=\\s*([^"']+?)(?=\\s|/|>)`, 'i').exec(content)
return unquoted?.[1]?.trim() || ''
}
function decodeXmlUrl(value: string): string {
const normalized = String(value || '')
.replace(/&amp;/g, '&')
.trim()
if (!normalized) return ''
if (!normalized.includes('%')) return normalized
try {
return decodeURIComponent(normalized)
} catch {
return normalized
}
}
function normalizeMd5(value: unknown): string | undefined {
const md5 = String(value || '')
.trim()
.toLowerCase()
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
}
function extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const match =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return normalizeMd5(match?.[1] || match?.[0])
}
function decodeSupplementalPayload(raw: unknown): string {
if (!raw) return ''
if (typeof raw === 'string' && !/^[a-fA-F0-9]+$/.test(raw.trim())) return raw.trim()
const buffer = decodePackedInfo(raw)
if (!buffer || buffer.length === 0) return ''
const decoded = buffer.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return Array.from(buffer)
.map((byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ' '))
.join('')
}
export function parseImageDatNameFromRow(row: Record<string, unknown>): string | undefined {
const packed = pickRowString(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info',
'WCDB_CT_PackedInfoData',
'WCDB_CT_PackedInfo'
])
const buffer = decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined
const printable = Array.from(buffer).map((byte) => (byte >= 0x20 && byte <= 0x7e ? byte : 0x20))
const text = Buffer.from(printable).toString('utf-8')
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
if (match?.[1]) return match[1].toLowerCase()
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
return hexMatch?.[1]?.toLowerCase()
}
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
const foundKey = Object.keys(row).find(
(candidate) => candidate.toLowerCase() === key.toLowerCase()
)
if (foundKey) return row[foundKey]
}
return undefined
}
function decodePackedInfo(raw: unknown): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
try {
return Buffer.from(trimmed, 'hex')
} catch {
// Try base64 below.
}
}
try {
return Buffer.from(trimmed, 'base64')
} catch {
// Unsupported packed_info encoding.
}
}
if (typeof raw === 'object' && raw && Array.isArray((raw as { data?: unknown }).data)) {
return Buffer.from((raw as { data: number[] }).data)
}
return null
}
+210
View File
@@ -0,0 +1,210 @@
import crypto from 'crypto'
import fs from 'fs-extra'
import http from 'http'
import https from 'https'
import os from 'os'
import path from 'path'
import { Wcdb4Client } from './wcdb4-client'
type StickerResult = { success: boolean; data?: string; error?: string }
const downloadCache = new Map<string, Promise<StickerResult>>()
export class StickerService {
private readonly cacheDir: string
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
}
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
const normalizedMd5 = this.normalizeMd5(md5)
let url = String(cdnUrl || '').trim()
if (!url && normalizedMd5 && this.wcdb4Client) {
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
if (!url) {
console.warn(`[StickerService] emoticon CDN URL not found for md5=${normalizedMd5}`)
}
}
if (!url) {
return { success: false, error: '未找到表情包 CDN URL' }
}
const cacheKey = normalizedMd5 || crypto.createHash('md5').update(url).digest('hex')
const cached = await this.readCached(cacheKey)
if (cached) return { success: true, data: cached }
if (normalizedMd5 && this.wcdb4Client) {
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
if (wechatCached) return { success: true, data: wechatCached }
}
const pending = downloadCache.get(cacheKey)
if (pending) return pending
const task = this.downloadToDataUrl(url, cacheKey)
downloadCache.set(cacheKey, task)
try {
return await task
} finally {
downloadCache.delete(cacheKey)
}
}
private async readCached(cacheKey: string): Promise<string | null> {
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
for (const cacheDir of cacheDirs) {
for (const ext of extensions) {
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
if (!fs.existsSync(filePath)) continue
const buffer = await fs.readFile(filePath)
return this.toDataUrl(buffer, ext)
}
}
return null
}
private async readWechatEmoticonCache(md5: string): Promise<string | null> {
const accountRoot = this.wcdb4Client?.getAccountRoot()
if (!accountRoot) return null
const cacheRoot = path.join(accountRoot, 'cache')
if (!fs.existsSync(cacheRoot)) return null
const prefix = md5.slice(0, 2)
let months: string[] = []
try {
months = fs
.readdirSync(cacheRoot)
.filter((name) => /^\d{4}-\d{2}$/.test(name))
.sort()
.reverse()
} catch {
return null
}
for (const month of months) {
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
if (!fs.existsSync(filePath)) continue
const buffer = await fs.readFile(filePath)
const ext = this.detectExtension(buffer) || '.gif'
return this.toDataUrl(buffer, ext)
}
return null
}
private downloadToDataUrl(
url: string,
cacheKey: string,
redirectCount = 0
): Promise<StickerResult> {
return new Promise((resolve) => {
if (redirectCount > 5) {
resolve({ success: false, error: '表情包下载重定向过多' })
return
}
const client = url.startsWith('https:') ? https : http
const request = client.get(
url,
{
headers: {
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
Referer: 'https://weixin.qq.com/'
}
},
(response) => {
const redirectUrl = response.headers.location
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
const nextUrl = new URL(redirectUrl, url).toString()
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
return
}
if (response.statusCode !== 200) {
console.warn(
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
)
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
return
}
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', async () => {
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) {
resolve({ success: false, error: '表情包内容为空' })
return
}
const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif'
try {
await fs.ensureDir(this.cacheDir)
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
} catch {
// Cache is best effort; the data URL can still be displayed.
}
resolve({ success: true, data: this.toDataUrl(buffer, ext) })
})
}
)
request.on('error', (error) => resolve({ success: false, error: error.message }))
request.setTimeout(15000, () => {
request.destroy()
resolve({ success: false, error: '表情包下载超时' })
})
})
}
private detectExtension(buffer: Buffer): string | null {
if (buffer.length >= 6 && buffer.subarray(0, 3).toString('ascii') === 'GIF') return '.gif'
if (
buffer.length >= 8 &&
buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
)
return '.png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
return '.jpg'
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp'
}
return null
}
private getExtFromUrl(url: string): string | null {
try {
const ext = path.extname(new URL(url).pathname).toLowerCase()
return ['.gif', '.png', '.webp', '.jpg', '.jpeg'].includes(ext) ? ext : null
} catch {
return null
}
}
private toDataUrl(buffer: Buffer, ext: string): string {
const mimeTypes: Record<string, string> = {
'.gif': 'image/gif',
'.png': 'image/png',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg'
}
return `data:${mimeTypes[ext] || 'image/gif'};base64,${buffer.toString('base64')}`
}
private normalizeMd5(value?: string): string | undefined {
const md5 = String(value || '')
.trim()
.toLowerCase()
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
}
}
+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])
}
}
File diff suppressed because it is too large Load Diff
+136 -2
View File
@@ -3,12 +3,14 @@ import fs from 'fs-extra'
import path from 'path'
import crypto from 'crypto'
import os from 'os'
import { Wcdb4Client } from './wcdb4-client'
type Database = import('better-sqlite3-multiple-ciphers').Database
export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
}
export interface WechatMessage {
@@ -26,6 +28,7 @@ export interface Contact {
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface GroupMemberInfo {
@@ -44,11 +47,19 @@ export class WechatDb {
private correctUserId: string | null = null
private chatDb: { name: string; db_number: string }[] | null = null
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
private wcdb4Client: Wcdb4Client | null = null
private chatMd5ToUsername = new Map<string, string>()
private wechat4OpenError: string | null = null
constructor(rawKey: string) {
this.rawKey = rawKey
console.log(`Initializing WechatDb with key: ${rawKey}`)
if (this.tryOpenWechat4()) {
this.chatDb = this.getChatDbNumber()
return
}
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
}
@@ -57,7 +68,24 @@ export class WechatDb {
console.log('User found, getting chat DB number')
this.chatDb = this.getChatDbNumber()
} else {
throw new Error('No valid user found or invalid key')
throw new Error(
`No valid user found or invalid key${this.wechat4OpenError ? `; WeChat 4.0 error: ${this.wechat4OpenError}` : ''}`
)
}
}
private tryOpenWechat4(): boolean {
try {
const client = new Wcdb4Client(this.rawKey)
client.open()
this.wcdb4Client = client
console.log('Opened WeChat 4.0 database with WechatExplorer WCDB native adapter')
return true
} catch (error) {
this.wechat4OpenError = error instanceof Error ? error.message : String(error)
console.warn('WeChat 4.0 open failed, fallback to 3.0 SQLCipher mode:', error)
this.wcdb4Client = null
return false
}
}
@@ -136,6 +164,17 @@ export class WechatDb {
}
private getChatDbNumber(): { name: string; db_number: string }[] {
if (this.wcdb4Client) {
const chatDb = this.wcdb4Client.getChatTables()
this.chatMd5ToUsername.clear()
for (const table of chatDb) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
return chatDb
}
const chatDb: { name: string; db_number: string }[] = []
if (!this.correctUserId) return []
@@ -158,6 +197,24 @@ export class WechatDb {
}
public getUserList(nicknameFilter?: string): UserContact[] {
if (this.wcdb4Client) {
const keyword = (nicknameFilter || '').trim().toLowerCase()
return this.wcdb4Client
.getSessions()
.map((session) => ({
m_nsUsrName: session.username,
nickname: session.nickname || session.username,
avatar: session.avatar
}))
.filter((contact) => {
if (!keyword) return true
return (
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
contact.nickname.toLowerCase().includes(keyword)
)
})
}
if (!this.correctUserId) return []
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
const db = this.connectDb(dbPath)
@@ -174,6 +231,16 @@ export class WechatDb {
}
public getAllGroupContacts(): Record<string, string> {
if (this.wcdb4Client) {
const groupContacts: Record<string, string> = {}
for (const session of this.wcdb4Client.getSessions()) {
if (session.username.endsWith('@chatroom')) {
groupContacts[this.md5(session.username)] = session.nickname || session.username
}
}
return groupContacts
}
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
@@ -196,6 +263,19 @@ export class WechatDb {
}
public getAllGroupMembers(): Record<string, string> {
if (this.wcdb4Client) {
const members: Record<string, string> = {}
for (const session of this.wcdb4Client.getSessions()) {
if (!session.username.endsWith('@chatroom')) continue
for (const member of this.wcdb4Client.getGroupMembers(session.username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
}
return members
}
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
@@ -218,7 +298,32 @@ export class WechatDb {
return groupMembers
}
public getGroupMember(wxid: string): GroupMemberInfo | null {
public getGroupMembersForChat(userMd5: string): Record<string, string> {
if (this.wcdb4Client) {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username || !username.endsWith('@chatroom')) return {}
const members: Record<string, string> = {}
for (const member of this.wcdb4Client.getGroupMembers(username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
return members
}
return this.getAllGroupMembers()
}
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (this.wcdb4Client && chatroomId) {
return (
this.wcdb4Client
.getGroupMembers(chatroomId)
.find((member) => member.m_nsUsrName === wxid) || null
)
}
if (!this.correctUserId) return null
// 检查缓存
@@ -251,7 +356,24 @@ export class WechatDb {
return this.chatDb || []
}
public getMyAvatarUrl(): string | undefined {
return this.wcdb4Client?.getMyAvatarUrl()
}
public getWcdb4Client(): Wcdb4Client | null {
return this.wcdb4Client
}
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
if (this.wcdb4Client) {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime).map((message) => ({
...message,
...message.raw
}))
}
if (!this.chatDb || !this.correctUserId) return []
const tableName = `Chat_${userMd5}`
@@ -302,6 +424,18 @@ export class WechatDb {
}
public searchAllMessages(keyword: string): string | null {
if (this.wcdb4Client) {
const lowerKeyword = keyword.trim().toLowerCase()
if (!lowerKeyword) return null
for (const session of this.wcdb4Client.getSessions()) {
const found = this.wcdb4Client
.getMessages(session.username)
.some((message) => message.msgContent.toLowerCase().includes(lowerKeyword))
if (found) return `Chat_${this.md5(session.username)}`
}
return null
}
if (!this.correctUserId) return null
for (let i = 0; i < 10; i++) {