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++) {
+46 -2
View File
@@ -1,19 +1,63 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
export type ParsedContent =
| { type: 'text'; content: string }
| { type: 'voice'; duration?: number }
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
type: 'sticker'
md5?: string
url?: string
thumbUrl?: string
encryptUrl?: string
aeskey?: string
}
| {
type: 'quote'
title?: string
content?: string
sender?: string
quotedContent?: string
quotedSender?: string
quotedType?: string
}
| { type: 'system'; content: string }
| { type: 'unknown'; raw: string }
declare global {
interface Window {
electron: ElectronAPI
api: {
initDb: (key: string) => Promise<boolean>
initDb: (key: string) => Promise<boolean | { success: boolean; error?: string }>
getContacts: (filter?: string) => Promise<Contact[]>
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
search: (keyword: string) => Promise<string | null>
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => Promise<{ success: boolean; data?: string; error?: string }>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
) => Promise<{ success: boolean; data?: string; error?: string }>
parseMessage: (content: string, messageType: number) => Promise<ParsedContent>
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
getSticker: (
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
}
}
}
+9 -2
View File
@@ -10,9 +10,16 @@ const api = {
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => ipcRenderer.invoke('ai:chat', messages, options),
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
parseMessage: (content: string, messageType: number) =>
ipcRenderer.invoke('db:parseMessage', content, messageType),
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5)
}
if (process.contextIsolated) {
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
/>
</head>
+4 -2
View File
@@ -23,12 +23,14 @@ function App(): React.ReactElement {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
try {
const success = await window.api.initDb(keyToUse)
const result = await window.api.initDb(keyToUse)
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsAuthenticated(true)
loadContacts()
} else {
alert('Failed to open database. Check your key.')
const error = typeof result === 'boolean' ? '' : result.error
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
}
} catch (error) {
console.error(error)
+606 -2
View File
@@ -133,6 +133,12 @@ body {
width: 12px;
}
.section-empty {
padding: 10px 14px 12px 27px;
color: #888;
font-size: 12px;
}
.contact-item {
padding: 10px;
display: flex;
@@ -159,6 +165,16 @@ body {
justify-content: center;
font-size: 12px;
color: #fff;
overflow: hidden;
flex-shrink: 0;
}
.contact-avatar img,
.message-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.contact-info {
@@ -215,6 +231,319 @@ body {
flex-direction: column;
}
.wechat-message-list {
padding: 18px 28px;
gap: 14px;
background-color: #edf1f2;
background-image:
radial-gradient(circle at 20px 20px, rgba(0, 0, 0, 0.025) 1px, transparent 1px),
radial-gradient(circle at 80px 70px, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
background-size: 120px 120px;
}
.wechat-message-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.wechat-message-row.mine {
justify-content: flex-end;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 6px;
background: #d6d6d6;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex: 0 0 38px;
font-size: 13px;
}
.mine-avatar {
background: #607d86;
}
.message-stack {
max-width: min(68%, 720px);
display: flex;
flex-direction: column;
align-items: flex-start;
}
.wechat-message-row.mine .message-stack {
align-items: flex-end;
}
.message-sender-name {
color: #6f777a;
font-size: 12px;
margin: 0 0 4px 2px;
}
.message-bubble {
position: relative;
padding: 9px 13px;
border-radius: 6px;
background: #fff;
color: #222;
font-size: 15px;
line-height: 1.55;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
word-break: break-word;
white-space: pre-wrap;
}
.wechat-message-row.mine .message-bubble {
background: #516d76;
color: #fff;
}
.message-bubble::before {
content: '';
position: absolute;
top: 12px;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.wechat-message-row.other .message-bubble::before {
left: -6px;
border-right: 6px solid #fff;
}
.wechat-message-row.mine .message-bubble::before {
right: -6px;
border-left: 6px solid #516d76;
}
.message-meta {
display: flex;
gap: 8px;
margin-top: 4px;
color: #9aa1a4;
font-size: 11px;
}
.message-text {
min-width: 0;
}
.voice-bubble {
min-width: 138px;
}
.image-message-bubble {
padding: 0;
overflow: hidden;
background: transparent;
box-shadow: none;
white-space: normal;
}
.wechat-message-row.mine .image-message-bubble {
background: transparent;
}
.image-message-bubble::before {
display: none;
}
.image-bubble {
position: relative;
max-width: min(260px, 46vw);
max-height: 220px;
min-width: 96px;
min-height: 72px;
border-radius: 7px;
overflow: hidden;
background: #fff;
cursor: zoom-in;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.image-content {
display: block;
max-width: min(260px, 46vw);
max-height: 220px;
width: auto;
height: auto;
object-fit: contain;
background: #f7f7f7;
}
.image-loading,
.image-placeholder,
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 18px;
color: #768184;
font-size: 13px;
}
.image-placeholder-icon {
font-size: 22px;
line-height: 1;
}
.image-actions {
position: absolute;
right: 6px;
bottom: 6px;
opacity: 0;
transition: opacity 0.15s ease;
}
.image-bubble:hover .image-actions {
opacity: 1;
}
.image-action-btn {
border: 0;
border-radius: 5px;
background: rgba(0, 0, 0, 0.48);
color: #fff;
cursor: pointer;
padding: 4px 6px;
}
.voice-message {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.voice-icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.08);
font-size: 10px;
line-height: 1;
}
.wechat-message-row.mine .voice-icon {
background: rgba(255, 255, 255, 0.18);
}
.voice-bars {
display: inline-flex;
align-items: center;
gap: 3px;
height: 16px;
}
.voice-bars i {
width: 3px;
border-radius: 3px;
background: currentColor;
opacity: 0.75;
}
.voice-bars i:nth-child(1) {
height: 7px;
}
.voice-bars i:nth-child(2) {
height: 13px;
}
.voice-bars i:nth-child(3) {
height: 9px;
}
.sticker-message {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.sticker-image {
display: block;
max-width: 140px;
max-height: 140px;
border-radius: 6px;
object-fit: contain;
}
.sticker-placeholder {
color: #666;
font-size: 14px;
}
.sticker-md5 {
max-width: 180px;
color: #999;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quote-message {
min-width: 160px;
max-width: 320px;
}
.quoted-message {
display: flex;
gap: 4px;
max-width: 100%;
margin-bottom: 8px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 1.4;
}
.wechat-message-row.other .quoted-message {
background: #f0f0f0;
color: #666;
}
.quoted-sender {
flex: 0 1 auto;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quoted-sender::after {
content: ':';
}
.quoted-text {
flex: 0 0 auto;
}
.quote-reply {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.chat-table {
width: 100%;
border-collapse: collapse;
@@ -272,7 +601,8 @@ body {
width: 150px;
}
.col-content {}
.col-content {
}
.chat-toolbar {
padding: 10px;
@@ -377,4 +707,278 @@ body {
background: transparent;
box-shadow: none;
padding: 0;
}
}
.image-viewer-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 24, 28, 0.28);
backdrop-filter: blur(1px);
}
.image-viewer-window {
width: min(900px, 86vw);
height: min(700px, 82vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 8px;
background: #eaf0f1;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
.image-viewer-titlebar {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.78);
color: #333;
font-size: 14px;
}
.image-viewer-tools {
display: flex;
align-items: center;
gap: 12px;
}
.image-viewer-title {
margin-right: 8px;
font-weight: 500;
}
.image-viewer-zoom {
min-width: 44px;
color: #666;
text-align: center;
}
.image-viewer-divider {
width: 1px;
height: 18px;
background: rgba(0, 0, 0, 0.12);
}
.image-viewer-titlebar button {
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #555;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.image-viewer-titlebar button:hover {
background: rgba(0, 0, 0, 0.08);
}
.image-viewer-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
overflow: auto;
cursor: grab;
user-select: none;
}
.image-viewer-stage img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
transform-origin: center center;
transition: transform 0.08s ease-out;
user-select: none;
pointer-events: none;
}
/* Voice Player */
.voice-message {
cursor: pointer;
user-select: none;
}
.voice-loading {
opacity: 0.6;
}
.voice-error {
opacity: 0.8;
}
.voice-loading-text {
font-size: 12px;
color: #999;
}
.voice-error-text {
color: #999;
}
.voice-duration {
font-size: 12px;
margin-left: 4px;
min-width: 32px;
}
.voice-icon.playing {
background: rgba(0, 0, 0, 0.15);
}
/* Rich Message Bubbles */
.location-message {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
}
.location-icon {
font-size: 28px;
flex-shrink: 0;
}
.location-info {
flex: 1;
overflow: hidden;
}
.location-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-label {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-coords {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.wechat-message-row.mine .location-message {
flex-direction: row-reverse;
}
.card-message {
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 240px;
}
.card-avatar {
width: 40px;
height: 40px;
border-radius: 6px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 500;
flex-shrink: 0;
}
.card-info {
flex: 1;
overflow: hidden;
}
.card-nickname {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-username {
font-size: 12px;
color: #666;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-username:hover {
color: #07c160;
}
.share-message {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
}
.share-appname {
font-size: 11px;
color: #999;
}
.share-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.share-desc {
font-size: 12px;
color: #666;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.share-url {
font-size: 11px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voip-message {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.voip-icon {
font-size: 16px;
}
.voip-status {
font-size: 13px;
}
+171 -96
View File
@@ -1,6 +1,9 @@
import React, { useEffect, useRef, useState } from 'react'
import { toPng } from 'html-to-image'
import { Message, Contact } from '../../../shared/types'
import { VoicePlayer } from './VoicePlayer'
import { RichMessageBubble } from './RichMessageBubble'
import { ImageBubble } from './ImageBubble'
interface ChatWindowProps {
contact: Contact | null
@@ -43,12 +46,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [showAvatar, setShowAvatar] = useState(false)
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true)
// AI Settings
const [showSettingsModal, setShowSettingsModal] = useState(false)
@@ -74,32 +79,54 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
scrollToBottom()
}, [messages])
const startResizing = (index: number, e: React.MouseEvent): void => {
e.preventDefault()
setResizingColIndex(index)
startXRef.current = e.clientX
startWidthRef.current = colWidths[index]
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleMouseMove = (e: MouseEvent): void => {
if (resizingColIndex === null) return
const diff = e.clientX - startXRef.current
const newWidth = Math.max(50, startWidthRef.current + diff)
const closeImagePreview = (): void => {
setPreviewImage(null)
imageDragRef.current = null
}
setColWidths((prev) => {
const newCols = [...prev]
newCols[resizingColIndex] = newWidth
return newCols
const zoomImage = (delta: number): void => {
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
}
const handleViewerMouseDown = (event: React.MouseEvent): void => {
event.preventDefault()
imageDragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: imageOffset.x,
offsetY: imageOffset.y
}
}
const handleViewerMouseMove = (event: React.MouseEvent): void => {
if (!imageDragRef.current) return
const drag = imageDragRef.current
setImageOffset({
x: drag.offsetX + event.clientX - drag.x,
y: drag.offsetY + event.clientY - drag.y
})
}
const handleMouseUp = (): void => {
setResizingColIndex(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
const handleViewerMouseUp = (): void => {
imageDragRef.current = null
}
const handleExport = (days: number | 'all'): void => {
@@ -172,8 +199,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const filteredMessages = messages
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
.map((msg) => {
const { img, id, isSender, ...rest } = msg
return rest
return {
from: msg.from,
type: msg.type,
datetime: msg.datetime,
content: msg.content,
name: msg.name
}
})
const recentMessages = filteredMessages
.map((msg) => {
@@ -248,8 +280,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '分享消息,图片,表情包,视频')
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
.split(',')
.map((type) => type.trim())
.filter(Boolean)
const typeMatch = !filterTypes.includes(msg.type)
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
@@ -274,74 +307,67 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<div className="window-controls"></div>
</div>
<div className="message-list">
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: colWidths[0], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
</th>
<th style={{ width: colWidths[1], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
</th>
<th style={{ width: colWidths[2], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
</th>
<th style={{ width: colWidths[3] }}></th>
</tr>
</thead>
<tbody>
{visibleMessages.map((msg) => (
<tr key={msg.id}>
<td
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={msg.from}
>
{msg.from}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.type}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.datetime}
</td>
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
{showAvatar && msg?.img && (
<img style={{ width: '40px', height: '40px' }} src={msg?.img}></img>
<div className="message-list wechat-message-list">
{visibleMessages.map((msg) => {
const isMine = msg.from === 'assistant'
const displayName = isMine
? '我'
: isGroupChat
? msg.name || msg.from
: contact.m_nsNickName
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
const isVoice = msg.type === '语音'
const isImage = msg.type === '图片'
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包'].includes(msg.type)
return (
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
{!isMine && showAvatar && (
<div className="message-avatar">
{avatarSrc ? (
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
) : (
(displayName || '?').charAt(0)
)}
<div
style={{
justifyContent: 'center',
alignItems: 'center',
display: !showAvatar ? 'flex' : 'block'
}}
>
{(msg.name || contact.m_nsNickName) && (
<div style={{ display: 'flex', fontSize: 18 }}>
{isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'}
<span>{isGroupChat ? (msg.name ? ':' : '') : ':'} </span>
</div>
)}
<div
style={{
fontSize: 18,
background: '#fff',
margin: 4,
padding: 4,
borderRadius: '4px'
}}
>
{msg.content}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="message-stack">
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
>
{isVoice && msg.sessionId ? (
<VoicePlayer
sessionId={msg.sessionId}
localId={msg.localId || 0}
createTime={msg.createTime || 0}
/>
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
<ImageBubble
imageMd5={msg.contentData.md5}
imageDatName={msg.contentData.datName}
sessionId={msg.sessionId}
onImageClick={openImagePreview}
/>
) : isRichMedia && msg.contentData ? (
<RichMessageBubble contentData={msg.contentData} />
) : (
<div className="message-text">{msg.content}</div>
)}
</div>
<div className="message-meta">
<span>{msg.datetime}</span>
<span>{msg.type}</span>
</div>
</div>
{isMine && showAvatar && (
<div className="message-avatar mine-avatar">
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
</div>
)}
</div>
)
})}
{filteredMessages.length > displayLimit && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<button
@@ -481,6 +507,55 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
)}
{previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
<div className="image-viewer-titlebar">
<div className="image-viewer-tools">
<span className="image-viewer-title"></span>
<button onClick={() => zoomImage(-0.1)} title="缩小">
</button>
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
<button onClick={() => zoomImage(0.1)} title="放大">
+
</button>
<span className="image-viewer-divider" />
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
</button>
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
</button>
<button onClick={resetImageTransform} title="重置">
</button>
</div>
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
×
</button>
</div>
<div
className="image-viewer-stage"
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
onMouseMove={handleViewerMouseMove}
onMouseUp={handleViewerMouseUp}
onMouseLeave={handleViewerMouseUp}
>
<img
src={previewImage}
alt="图片预览"
draggable={false}
style={{
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
}}
/>
</div>
</div>
</div>
)}
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
+108
View File
@@ -0,0 +1,108 @@
import { useState, useCallback, useEffect } from 'react'
import type { JSX, MouseEvent } from 'react'
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
onImageClick?: (imageUrl: string) => void
}
export function ImageBubble({
imageMd5,
imageDatName,
sessionId,
isThumb = false,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
setError('缺少图片标识')
return
}
setLoading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
if (result.success && result.data) {
// 验证返回的是否是有效的图片 data URL
if (result.data.startsWith('data:image/')) {
setImageUrl(result.data)
setError(null)
} else {
// 解密后不是有效图片格式,显示未解密
setError('未解密')
}
} else {
setError(result.error || '加载图片失败')
}
} catch {
setError('加载图片失败')
}
setLoading(false)
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
useEffect(() => {
if (imageUrl || loading || error) return
const timer = window.setTimeout(() => {
void loadImage()
}, 0)
return () => window.clearTimeout(timer)
}, [error, imageUrl, loadImage, loading])
const handleCopy = async (event: MouseEvent): Promise<void> => {
event.stopPropagation()
if (imageUrl) {
await window.api.copyImage(imageUrl)
alert('图片已复制')
}
}
const handleClick = (): void => {
if (imageUrl) {
onImageClick?.(imageUrl)
}
}
if (loading) {
return (
<div className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-spinner">...</div>
</div>
)
}
if (error) {
return (
<div className="image-bubble image-error" onClick={loadImage}>
<div className="image-error-text"></div>
</div>
)
}
if (!imageUrl) {
return (
<div className="image-bubble image-placeholder">
<div className="image-placeholder-icon">🖼</div>
<div className="image-placeholder-text"></div>
</div>
)
}
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
<div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
📋
</button>
</div>
</div>
)
}
@@ -0,0 +1,222 @@
import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps {
contentData: ParsedContent
}
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return <LocationBubble data={contentData} />
case 'card':
return <CardBubble data={contentData} />
case 'share':
return <ShareBubble data={contentData} />
case 'voip':
return <VoipBubble data={contentData} />
case 'sticker':
return <StickerBubble data={contentData} />
case 'quote':
return <QuoteBubble data={contentData} />
case 'unknown':
return (
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
)
default:
return <div className="message-text">[]</div>
}
}
function LocationBubble({
data
}: {
data: Extract<ParsedContent, { type: 'location' }>
}): JSX.Element {
const { poiname, label, lat, lng } = data
const locationText = poiname || label || '位置'
const hasCoords = lat !== 0 || lng !== 0
const handleClick = (): void => {
if (hasCoords) {
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
window.open(url, '_blank')
}
}
return (
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
<div className="location-icon">📍</div>
<div className="location-info">
<div className="location-name">{locationText}</div>
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
{hasCoords && (
<div className="location-coords">
{lat.toFixed(6)}, {lng.toFixed(6)}
</div>
)}
</div>
</div>
)
}
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
const { username, nickname } = data
const handleCopy = (e: MouseEvent): void => {
e.stopPropagation()
navigator.clipboard.writeText(username)
}
return (
<div className="card-message">
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
<div className="card-info">
<div className="card-nickname">{nickname || '未知'}</div>
<div className="card-username" onClick={handleCopy} title="点击复制">
{username}
</div>
</div>
</div>
)
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname } = data
const handleClick = (): void => {
if (url) {
window.open(url, '_blank')
}
}
const urlHost = getUrlHost(url)
return (
<div className="share-message" onClick={handleClick}>
{appname && <div className="share-appname">{appname}</div>}
<div className="share-title">{title || '链接'}</div>
{des && <div className="share-desc">{des}</div>}
{urlHost && <div className="share-url">{urlHost}</div>}
</div>
)
}
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
const { status, roomType, duration } = data
const isVideo = roomType === 1
const formatDuration = (seconds: number | undefined): string => {
if (!seconds) return ''
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins > 0) {
return `${mins}${secs}`
}
return `${secs}`
}
return (
<div className="voip-message">
<span className="voip-icon">{isVideo ? '📹' : '📞'}</span>
<span className="voip-status">
{status}
{duration ? ` ${formatDuration(duration)}` : ''}
</span>
</div>
)
}
function StickerBubble({
data
}: {
data: Extract<ParsedContent, { type: 'sticker' }>
}): JSX.Element {
const { md5, url, thumbUrl } = data
const sourceUrl = url || thumbUrl || ''
const cacheKey = md5 || sourceUrl
const [displayUrl, setDisplayUrl] = useState(() =>
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
)
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
const [error, setError] = useState(false)
useEffect(() => {
if (!cacheKey || displayUrl || error) return
let cancelled = false
window.api
.getSticker(sourceUrl, md5)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
stickerDataUrlCache.set(cacheKey, result.data)
setDisplayUrl(result.data)
setError(false)
} else {
setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [cacheKey, displayUrl, error, md5, sourceUrl])
if (displayUrl) {
return (
<div className="sticker-message">
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
</div>
)
}
if (loading) {
return (
<div className="sticker-message">
<div className="sticker-placeholder">...</div>
</div>
)
}
return (
<div className="sticker-message">
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
</div>
)
}
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
return (
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{quotedText}</span>
</div>
{replyText && <div className="quote-reply">{replyText}</div>}
</div>
)
}
function getUrlHost(url?: string): string {
if (!url) return ''
try {
return new URL(url).hostname
} catch {
return url
}
}
+11 -1
View File
@@ -48,7 +48,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
<div className="contact-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt={contact.m_nsNickName} referrerPolicy="no-referrer" />
) : (
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.m_nsNickName}</div>
</div>
@@ -98,12 +104,16 @@ export const Sidebar: React.FC<SidebarProps> = ({
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> ({groups.length})
</div>
{isGroupsExpanded && groups.map(renderContactItem)}
{isGroupsExpanded && groups.length === 0 && <div className="section-empty"></div>}
{/* 联系人部分 */}
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> ({users.length})
</div>
{isContactsExpanded && users.map(renderContactItem)}
{isContactsExpanded && users.length === 0 && (
<div className="section-empty"></div>
)}
</div>
{/* <div className="sidebar-footer">
<div className="sidebar-btn" onClick={() => window.location.reload()}>
+195
View File
@@ -0,0 +1,195 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import type { JSX } from 'react'
interface VoicePlayerProps {
sessionId: string
localId: number
createTime: number
svrId?: string | number
duration?: number
}
let globalCurrentAudio: HTMLAudioElement | null = null
let globalStopCallback: (() => void) | null = null
export function VoicePlayer({
sessionId,
localId,
createTime,
svrId
}: VoicePlayerProps): JSX.Element {
const [isPlaying, setIsPlaying] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
if (globalCurrentAudio && globalCurrentAudio !== audio) {
globalCurrentAudio.pause()
globalCurrentAudio.currentTime = 0
globalStopCallback?.()
}
globalCurrentAudio = audio
}, [])
const handlePlayPause = useCallback(async () => {
// 如果还没有音频数据,先获取
if (!audioUrl && !loading) {
setLoading(true)
setShouldAutoPlay(true)
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
try {
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
console.log('[VoicePlayer] got result:', result)
if (result.success && result.data) {
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
const byteCharacters = atob(result.data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'audio/wav' })
const blobUrl = URL.createObjectURL(blob)
console.log('[VoicePlayer] created blob URL:', blobUrl)
setAudioUrl(blobUrl)
} else {
console.log('[VoicePlayer] getVoiceData failed:', result.error)
setError(result.error || '获取语音数据失败')
setShouldAutoPlay(false)
}
} catch (e) {
console.log('[VoicePlayer] exception:', e)
setError('加载语音失败')
setShouldAutoPlay(false)
}
setLoading(false)
return
}
if (!audioRef.current) {
console.log('[VoicePlayer] no audioRef')
return
}
const audio = audioRef.current
if (isPlaying) {
audio.pause()
setIsPlaying(false)
globalStopCallback = null
} else {
stopCurrentAndPlay(audio)
audio
.play()
.then(() => {
console.log('[VoicePlayer] play() succeeded')
})
.catch((e) => {
console.log('[VoicePlayer] play() failed:', e)
})
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
audio.currentTime = 0
}
}
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
useEffect(() => {
if (!audioUrl) return
let audio = audioRef.current
if (!audio) {
audio = new Audio(audioUrl)
audioRef.current = audio
}
const audioEl = audio!
audioEl.addEventListener('loadedmetadata', () => {
setAudioDuration(audioEl.duration)
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
})
audioEl.addEventListener('ended', () => {
setIsPlaying(false)
globalStopCallback = null
})
audioEl.addEventListener('timeupdate', () => {
if (audioEl.duration && isFinite(audioEl.duration)) {
setAudioDuration(audioEl.duration)
}
})
audioEl.addEventListener('canplay', () => {
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
if (shouldAutoPlay && audioRef.current) {
setShouldAutoPlay(false)
stopCurrentAndPlay(audioRef.current)
audioRef.current.play()
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
if (audioRef.current) {
audioRef.current.currentTime = 0
}
}
}
})
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ''
audioRef.current = null
}
if (globalCurrentAudio === audioRef.current) {
globalCurrentAudio = null
globalStopCallback = null
}
}
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (loading) {
return (
<div className="voice-message voice-loading">
<span className="voice-icon"></span>
<span className="voice-loading-text">...</span>
</div>
)
}
if (error && !audioUrl) {
return (
<div className="voice-message voice-error" onClick={handlePlayPause}>
<span className="voice-icon"></span>
<span className="voice-error-text">[]</span>
</div>
)
}
return (
<div className="voice-message" onClick={handlePlayPause}>
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
<div className="voice-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</div>
<span className="voice-duration">{formatDuration(audioDuration)}</span>
</div>
)
}
+65
View File
@@ -15,8 +15,73 @@ export interface Message {
isSender: boolean
img?: string
name?: string
contentData?: ParsedContent
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
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 interface ChatTable {
name: string
db_number: string