diff --git a/.env.example b/.env.example index 9f8be6d..145a73c 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,12 @@ VITE_DEEPSEEK_API_KEY= VITE_AI_BASE_URL=https://api.deepseek.com VITE_AI_MODEL=deepseek-chat -# Message types to filter out (comma separated) -VITE_FILTER_MSG_TYPES=分享消息,图片,表情包,视频 +# Message types to filter out (comma separated). Empty means show all message types. +VITE_FILTER_MSG_TYPES= + +# Image Decryption Keys (Optional, for WeChat 4.0+ image decryption) +# These are used to decrypt image .dat files in WeChat 4.0+ +# XOR Key: hex format like 0x40, 0x53 etc. +# AES Key: 16-character string, derived from wxid and code +VITE_IMAGE_XOR_KEY= +VITE_IMAGE_AES_KEY= diff --git a/.gitignore b/.gitignore index 25730a6..660d1d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ node_modules dist out +docs/ .env .DS_Store .eslintcache *.log* -.omc \ No newline at end of file +.omc diff --git a/README.md b/README.md index 962cb21..9b0f3be 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,38 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 - **微信版本**: 推荐使用微信 **4.0 以下**版本(4.0以上版本不支持数据库解密) - 微信 <= 4.0: 可正常使用,获取数据库密码方式参考:[Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/) - - 微信 >= 4.0: 如需使用,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) + - 微信 >= 4.0: 可正常使用 (正在迭代) ,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog) - 如无法获取本地数据库密码,则无法使用当前项目 - Node.js (推荐 v16+) - pnpm@7 - 解密后的微信数据库文件 (`.db`) 和对应的密钥 - AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等) +### 环境变量配置 (.env) + +可选配置项,可在 `.env` 文件中设置: + +| 变量名 | 说明 | 示例 | +|--------|------|------| +| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` | +| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` | +| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` | +| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` | +| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` | +| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` | +| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` | + +#### 图片解密密钥说明 + +微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密: + +- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密 +- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密 + +这两个密钥可以通过以下方式获取: +1. 从 WeFlow/Chatlog 设置中导出 +2. 使用内存扫描工具从微信进程中自动提取(待实现) + ## ⚠️ 免责声明 本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。 @@ -47,4 +72,5 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 ## 🔗 参考 - [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer) -- [WeFlow](https://github.com/hicccc77/WeFlow) +- [WechatExplorer](https://github.com/hicccc77/WechatExplorer) +- [chatlog](https://github.com/sjzar/chatlog) diff --git a/electron-builder.yml b/electron-builder.yml index b3d8672..a48e31e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,6 +14,11 @@ extraMetadata: main: out/main/index.js asarUnpack: - resources/** +extraResources: + - from: resources + to: resources + filter: + - '**/*' win: executableName: wechatexplorer nsis: diff --git a/package.json b/package.json index f73aab8..13ad8b3 100644 --- a/package.json +++ b/package.json @@ -33,13 +33,17 @@ "@electron-toolkit/utils": "^4.0.0", "better-sqlite3-multiple-ciphers": "^12.5.0", "fs-extra": "^11.3.2", + "fzstd": "^0.1.1", "html-to-image": "^1.11.13", - "openai": "^6.10.0" + "koffi": "^2.9.0", + "openai": "^6.10.0", + "silk-wasm": "^3.7.1" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@rollup/rollup-darwin-arm64": "^4.62.2", "@types/fs-extra": "^11.0.4", "@types/node": "^22.19.1", "@types/react": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe779c..f111d83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ specifiers: '@electron-toolkit/preload': ^3.0.2 '@electron-toolkit/tsconfig': ^2.0.0 '@electron-toolkit/utils': ^4.0.0 + '@rollup/rollup-darwin-arm64': ^4.62.2 '@types/fs-extra': ^11.0.4 '@types/node': ^22.19.1 '@types/react': ^19.2.7 @@ -24,11 +25,14 @@ specifiers: eslint-plugin-react-hooks: ^7.0.1 eslint-plugin-react-refresh: ^0.4.24 fs-extra: ^11.3.2 + fzstd: ^0.1.1 html-to-image: ^1.11.13 + koffi: ^2.9.0 openai: ^6.10.0 prettier: ^3.7.4 react: ^19.2.1 react-dom: ^19.2.1 + silk-wasm: ^3.7.1 typescript: ^5.9.3 vite: ^7.2.6 @@ -37,13 +41,17 @@ dependencies: '@electron-toolkit/utils': 4.0.0_electron@39.2.6 better-sqlite3-multiple-ciphers: 12.5.0 fs-extra: 11.3.2 + fzstd: 0.1.1 html-to-image: 1.11.13 + koffi: 2.16.2 openai: 6.10.0 + silk-wasm: 3.7.1 devDependencies: '@electron-toolkit/eslint-config-prettier': 3.0.0_fiitszekoa4sqtbwyewxi6kyy4 '@electron-toolkit/eslint-config-ts': 3.1.0_ohw7ybem3stexzc2ktgcr2xgzi '@electron-toolkit/tsconfig': 2.0.0_@types+node@22.19.2 + '@rollup/rollup-darwin-arm64': 4.62.2 '@types/fs-extra': 11.0.4 '@types/node': 22.19.2 '@types/react': 19.2.7 @@ -854,6 +862,12 @@ packages: dev: true optional: true + /@rollup/rollup-darwin-arm64/4.62.2: + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + dev: true + /@rollup/rollup-darwin-x64/4.53.3: resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} cpu: [x64] @@ -2717,6 +2731,10 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /fzstd/0.1.1: + resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==} + dev: false + /generator-function/2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3387,6 +3405,10 @@ packages: dependencies: json-buffer: 3.0.1 + /koffi/2.16.2: + resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==} + dev: false + /lazy-val/1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} dev: true @@ -4335,6 +4357,11 @@ packages: engines: {node: '>=14'} dev: true + /silk-wasm/3.7.1: + resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==} + engines: {node: '>=16.11.0'} + dev: false + /simple-concat/1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} dev: false diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib new file mode 100755 index 0000000..07cb87f Binary files /dev/null and b/resources/libwcdb_api.dylib differ diff --git a/resources/libwx_key.dylib b/resources/libwx_key.dylib new file mode 100755 index 0000000..59c673a Binary files /dev/null and b/resources/libwx_key.dylib differ diff --git a/resources/macos/libWCDB.dylib b/resources/macos/libWCDB.dylib new file mode 100755 index 0000000..75eb279 Binary files /dev/null and b/resources/macos/libWCDB.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib new file mode 100755 index 0000000..db376bb Binary files /dev/null and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/xkey_helper b/resources/xkey_helper new file mode 100755 index 0000000..1c9b951 Binary files /dev/null and b/resources/xkey_helper differ diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts new file mode 100644 index 0000000..55674b1 --- /dev/null +++ b/src/main/image-decrypt-service.ts @@ -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 = { + '.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 = { + '.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 + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 47584b3..b603575 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = { 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 | undefined = undefined + let displayType = MSG_TYPE_DICT[msgType] || msg.messageType + const inferredMsgType = + typeof content === 'string' && + / { } }) + 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 () { diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts new file mode 100644 index 0000000..47be29c --- /dev/null +++ b/src/main/message-parser.ts @@ -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 格式: + 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, + 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('')) { + 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('') + const referMsgEnd = content.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) return {} + + const referMsgXml = content.substring(referMsgStart, referMsgEnd + ''.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 = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (!appmsgMatch) return extractXmlValue(content, 'type') + const inner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\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}[^>]*>`, 'i'), + new RegExp(`<${tagName}[^>]*>`, 'i'), + new RegExp(`<${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(/&/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 | 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, 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 +} diff --git a/src/main/sticker-service.ts b/src/main/sticker-service.ts new file mode 100644 index 0000000..c32e3b2 --- /dev/null +++ b/src/main/sticker-service.ts @@ -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>() + +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 { + 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 { + 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 { + 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 { + 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 = { + '.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 + } +} diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts new file mode 100644 index 0000000..dc40fda --- /dev/null +++ b/src/main/voice-service.ts @@ -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() + + 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 { + 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]) + } +} diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts new file mode 100644 index 0000000..71b2365 --- /dev/null +++ b/src/main/wcdb4-client.ts @@ -0,0 +1,1176 @@ +import fs from 'fs-extra' +import path from 'path' +import os from 'os' +import crypto from 'crypto' +import { createRequire } from 'module' + +export interface Wcdb4Session { + username: string + nickname: string + avatar?: string + raw: Record +} + +export interface Wcdb4Message { + mesLocalID: string + mesDes: number + messageType: string + msgCreateTime: string + msgContent: string + sender?: string + senderNickname?: string + senderAvatar?: string + raw: Record +} + +export interface Wcdb4GroupMember { + m_nsUsrName: string + nickname: string + m_nsHeadImgUrl: string +} + +export interface Wcdb4ImageHardlink { + file_name?: string + full_path?: string + [key: string]: unknown +} + +type KoffiModule = { + load: (libraryPath: string) => KoffiLibrary + decode: (ptr: unknown, type: string, length: number) => string +} + +type KoffiLibrary = { + func: (signature: string) => (...args: unknown[]) => unknown +} + +type WcdbVoidOut = [unknown] +type WcdbHandleOut = [number] + +const nodeRequire = createRequire(import.meta.url) + +export class Wcdb4Client { + static readonly defaultRoot = path.join( + os.homedir(), + 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files' + ) + + private readonly key: string + private readonly accountRoot: string + private readonly wxid: string + private readonly dbStoragePath: string + private readonly sessionDbPath: string + private koffi: KoffiModule | null = null + private handle: number | null = null + private initialized = false + private displayNameCache = new Map() + private avatarCache = new Map() + private cachedSessions: Wcdb4Session[] | null = null + + private wcdbInit: (() => number) | null = null + private wcdbShutdown: (() => number) | null = null + private wcdbOpenAccount: + | ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number) + | null = null + private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null + private wcdbFreeString: ((ptr: unknown) => void) | null = null + private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null + private wcdbGetMessages: + | (( + handle: number, + username: string, + limit: number, + offset: number, + outJson: WcdbVoidOut + ) => number) + | null = null + private wcdbGetDisplayNames: + | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbGetAvatarUrls: + | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbExecQuery: + | ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbGetGroupMembers: + | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbOpenMessageCursor: + | (( + handle: number, + username: string, + batchSize: number, + ascending: number, + beginTimestamp: number, + endTimestamp: number, + cursorOut: WcdbHandleOut + ) => number) + | null = null + private wcdbFetchMessageBatch: + | ((handle: number, cursor: number, outJson: WcdbVoidOut, outHasMore: [number]) => number) + | null = null + private wcdbCloseMessageCursor: ((handle: number, cursor: number) => number) | null = null + private wcdbGetVoiceData: + | (( + handle: number, + sessionId: string, + createTime: number, + localId: number, + svrId: bigint, + candidatesJson: string, + outHex: WcdbVoidOut + ) => number) + | null = null + private wcdbResolveImageHardlink: + | ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbGetEmoticonCdnUrl: + | ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number) + | null = null + + constructor(key: string, accountRoot?: string) { + this.key = key.replace(/^0x/i, '').trim() + this.accountRoot = accountRoot || Wcdb4Client.findLatestAccountRoot() + this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot)) + this.dbStoragePath = path.join(this.accountRoot, 'db_storage') + this.sessionDbPath = this.findSessionDb() + + if (!this.sessionDbPath) { + throw new Error(`未找到微信 4.0 session.db: ${this.dbStoragePath}`) + } + } + + static findLatestAccountRoot(): string { + const root = Wcdb4Client.defaultRoot + if (!fs.existsSync(root)) { + throw new Error(`未找到微信 4.0 数据目录: ${root}`) + } + + if (fs.existsSync(path.join(root, 'db_storage'))) { + return root + } + + const candidates = fs + .readdirSync(root) + .map((name) => path.join(root, name)) + .filter((candidate) => { + try { + return ( + fs.statSync(candidate).isDirectory() && + fs.existsSync(path.join(candidate, 'db_storage')) + ) + } catch { + return false + } + }) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs) + + if (!candidates[0]) { + throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${root}`) + } + + return candidates[0] + } + + private static cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + open(): void { + this.loadNativeLibrary() + if (!this.wcdbInit || !this.wcdbOpenAccount) { + throw new Error('WCDB 4.0 native 接口未就绪') + } + + if (!this.initialized) { + const initResult = this.wcdbInit() + if (initResult !== 0) { + console.warn(`wcdb_init 返回 ${initResult},继续尝试 wcdb_open_account`) + } else { + this.initialized = true + } + } + + const handleOut: WcdbHandleOut = [0] + const openResult = this.wcdbOpenAccount(this.sessionDbPath, this.key, handleOut) + if (openResult !== 0 || handleOut[0] <= 0) { + throw new Error( + `wcdb_open_account 失败,错误码: ${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}` + ) + } + + this.handle = handleOut[0] + if (this.wcdbSetMyWxid) { + try { + this.wcdbSetMyWxid(this.handle, this.wxid) + } catch { + // Optional helper. Failure does not block message reads. + } + } + } + + close(): void { + if (!this.initialized || !this.wcdbShutdown) return + + try { + this.wcdbShutdown() + } catch { + // Mirror WechatExplorer: shutdown is best-effort on app close. + } + + this.handle = null + this.initialized = false + this.cachedSessions = null + this.displayNameCache.clear() + this.avatarCache.clear() + } + + getSessions(): Wcdb4Session[] { + if (this.cachedSessions) return this.cachedSessions + if (!this.wcdbGetSessions) return [] + + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetSessions!(handle, outJson) + ) + + const sessions = (Array.isArray(rows) ? rows : []) + .map((row) => this.normalizeSession(row)) + .filter((session) => session.username) + + const sessionUsernames = sessions.map((session) => session.username) + this.hydrateDisplayNames(sessionUsernames) + this.hydrateAvatarUrls(sessionUsernames) + this.cachedSessions = sessions.map((session) => ({ + ...session, + nickname: this.displayNameCache.get(session.username) || session.nickname || session.username, + avatar: this.avatarCache.get(session.username) + })) + + return this.cachedSessions + } + + getChatTables(): { name: string; db_number: string }[] { + return this.getSessions().map((session) => ({ + name: `Chat_${this.md5(session.username)}`, + db_number: session.username + })) + } + + getMessages(username: string, startTime?: number, endTime?: number): Wcdb4Message[] { + const cursorMessages = this.getMessagesByCursor(username, startTime, endTime) + if (cursorMessages) return cursorMessages + + if (!this.wcdbGetMessages) return [] + + const allRows: Record[] = [] + const limit = 1000 + let offset = 0 + + while (true) { + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetMessages!(handle, username, limit, offset, outJson) + ) + const batch = Array.isArray(rows) ? rows : [] + allRows.push(...batch) + if (batch.length < limit) break + offset += limit + } + + return this.finalizeMessages(username, allRows, startTime, endTime) + } + + getMyAvatarUrl(): string | undefined { + const rawAccountName = path.basename(this.accountRoot) + const candidates = this.uniq([ + this.wxid, + rawAccountName, + Wcdb4Client.cleanAccountDirName(rawAccountName) + ]) + this.hydrateAvatarUrls(candidates) + + for (const candidate of candidates) { + const avatar = this.avatarCache.get(candidate) + if (avatar) return avatar + } + + return undefined + } + + private getMessagesByCursor( + username: string, + startTime?: number, + endTime?: number + ): Wcdb4Message[] | null { + if ( + !this.wcdbOpenMessageCursor || + !this.wcdbFetchMessageBatch || + !this.wcdbCloseMessageCursor + ) { + return null + } + + const handle = this.ensureHandle() + const batchSize = 1000 + const cursorOut: WcdbHandleOut = [0] + const begin = this.normalizeTimestamp(startTime || 0) + const end = this.normalizeTimestamp(endTime || 0) + const openResult = this.wcdbOpenMessageCursor( + handle, + username, + batchSize, + 1, + begin, + end, + cursorOut + ) + if (openResult !== 0 || cursorOut[0] <= 0) { + return null + } + + const cursor = cursorOut[0] + const allRows: Record[] = [] + + try { + while (true) { + const outJson: WcdbVoidOut = [null] + const outHasMore: [number] = [0] + const fetchResult = this.wcdbFetchMessageBatch!(handle, cursor, outJson, outHasMore) + if (fetchResult !== 0 || !outJson[0]) break + + try { + const json = this.koffi!.decode(outJson[0], 'char', -1) + const batch = JSON.parse(json) as Record[] + if (Array.isArray(batch)) allRows.push(...batch) + } finally { + this.wcdbFreeString?.(outJson[0]) + } + + if (!outHasMore[0]) break + } + } finally { + try { + this.wcdbCloseMessageCursor?.(handle, cursor) + } catch { + // Best effort cleanup; a stale cursor is less harmful than blocking UI. + } + } + + return this.finalizeMessages(username, allRows, startTime, endTime) + } + + private finalizeMessages( + username: string, + rows: Record[], + startTime?: number, + endTime?: number + ): Wcdb4Message[] { + const messages = rows.map((row) => this.normalizeMessage(row)) + const senderIds = messages.map((message) => message.sender || '').filter(Boolean) + this.hydrateDisplayNames(senderIds) + this.hydrateAvatarUrls(senderIds) + + return messages + .filter((message) => { + const createTime = Number(message.msgCreateTime) + if (startTime && createTime < startTime) return false + if (endTime && createTime > endTime) return false + return true + }) + .sort((a, b) => Number(a.msgCreateTime) - Number(b.msgCreateTime)) + .map((message) => { + if (!message.sender) return message + const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname + const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar + const shouldPrefixSender = + username.endsWith('@chatroom') && + message.mesDes === 1 && + message.sender && + message.msgContent && + !message.msgContent.startsWith(`${message.sender}:`) + return { + ...message, + senderNickname, + senderAvatar, + msgContent: shouldPrefixSender + ? `${message.sender}:\n${message.msgContent}` + : message.msgContent + } + }) + } + + getGroupMembers(chatroomId: string): Wcdb4GroupMember[] { + if (!this.wcdbGetGroupMembers || !chatroomId) return [] + + try { + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetGroupMembers!(handle, chatroomId, outJson) + ) + + return (Array.isArray(rows) ? rows : []).map((row) => { + const username = this.pickString(row, [ + 'username', + 'userName', + 'user_name', + 'member_username', + 'm_nsUsrName' + ]) + const nickname = this.pickString(row, [ + 'nickname', + 'displayName', + 'display_name', + 'remark', + 'm_nsNickName' + ]) + const avatar = this.pickString(row, [ + 'avatarUrl', + 'avatar_url', + 'headImgUrl', + 'm_nsHeadImgUrl' + ]) + + if (username) { + if (nickname) this.displayNameCache.set(username, nickname) + if (avatar) this.avatarCache.set(username, avatar) + } + + return { + m_nsUsrName: username, + nickname: nickname || username, + m_nsHeadImgUrl: avatar + } + }) + } catch { + return [] + } + } + + async getVoiceData( + sessionId: string, + createTime: number, + candidates: string[], + localId: number = 0, + svrId: string | number = 0 + ): Promise<{ success: boolean; hex?: string; error: string }> { + if (!this.wcdbGetVoiceData) { + return { success: false, error: '当前 DLL 版本不支持获取语音数据' } + } + + const handle = this.ensureHandle() + const outHex: WcdbVoidOut = [null] + + try { + const result = this.wcdbGetVoiceData( + handle, + sessionId, + createTime, + localId, + BigInt(svrId || 0), + JSON.stringify(candidates), + outHex + ) + + if (result !== 0 || !outHex[0]) { + return { success: false, error: `获取语音数据失败: ${result}` } + } + + const hex = this.decodeHexPtr(outHex[0]) + if (hex === null) { + return { success: false, error: '解析语音数据失败' } + } + + return { success: true, hex, error: '' } + } finally { + this.wcdbFreeString?.(outHex[0]) + } + } + + private decodeHexPtr(ptr: unknown): string | null { + if (!ptr || !this.koffi) return null + try { + const hex = this.koffi.decode(ptr, 'char', -1) + return typeof hex === 'string' ? hex : null + } catch { + return null + } + } + + getUsernameByMd5(md5: string): string | undefined { + return this.getSessions().find((session) => this.md5(session.username) === md5)?.username + } + + getAccountRoot(): string { + return this.accountRoot + } + + resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null { + if (!this.wcdbResolveImageHardlink) return null + const normalizedMd5 = String(md5 || '') + .trim() + .toLowerCase() + if (!normalizedMd5) return null + + try { + return this.callJson((handle, outJson) => + this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson) + ) + } catch (error) { + console.warn('[WCDB4] resolve image hardlink failed:', error) + return null + } + } + + resolveEmoticonCdnUrl(md5: string): string | undefined { + if (!this.wcdbGetEmoticonCdnUrl) { + console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`) + return undefined + } + const normalizedMd5 = String(md5 || '') + .trim() + .toLowerCase() + if (!/^[a-f0-9]{32}$/.test(normalizedMd5)) return undefined + + const dbPath = this.findEmoticonDb() + if (!dbPath) { + console.warn(`[WCDB4] emoticon.db not found for md5=${normalizedMd5}`) + return undefined + } + + const outUrl: WcdbVoidOut = [null] + try { + const result = this.wcdbGetEmoticonCdnUrl(this.ensureHandle(), dbPath, normalizedMd5, outUrl) + if (result !== 0 || !outUrl[0] || !this.koffi) { + console.warn( + `[WCDB4] emoticon CDN URL lookup miss: result=${result}; md5=${normalizedMd5}; db=${dbPath}` + ) + return undefined + } + const url = this.koffi.decode(outUrl[0], 'char', -1).trim() + return url || undefined + } catch (error) { + console.warn('[WCDB4] resolve emoticon CDN URL failed:', error) + return undefined + } finally { + this.wcdbFreeString?.(outUrl[0]) + } + } + + md5(value: string): string { + return crypto.createHash('md5').update(value).digest('hex') + } + + private loadNativeLibrary(): void { + if (this.koffi) return + + const koffi = nodeRequire('koffi') as KoffiModule + this.koffi = koffi + + const libPath = this.findNativeLibrary() + const libDir = path.dirname(libPath) + const wcdbCorePath = path.join(libDir, 'libWCDB.dylib') + if (fs.existsSync(wcdbCorePath)) { + try { + koffi.load(wcdbCorePath) + } catch { + // Some builds resolve this dependency through rpath. + } + } + + const lib = koffi.load(libPath) + this.initProtection(lib, libDir) + + this.wcdbInit = lib.func('int32 wcdb_init()') as () => number + this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number + this.wcdbOpenAccount = lib.func( + 'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)' + ) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number + this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void + this.wcdbGetSessions = lib.func( + 'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)' + ) as (handle: number, outJson: WcdbVoidOut) => number + this.wcdbGetMessages = lib.func( + 'int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)' + ) as ( + handle: number, + username: string, + limit: number, + offset: number, + outJson: WcdbVoidOut + ) => number + this.wcdbGetDisplayNames = lib.func( + 'int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)' + ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number + + try { + this.wcdbSetMyWxid = lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)') as ( + handle: number, + wxid: string + ) => number + } catch { + this.wcdbSetMyWxid = null + } + + try { + this.wcdbGetAvatarUrls = lib.func( + 'int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)' + ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetAvatarUrls = null + } + + try { + this.wcdbExecQuery = lib.func( + 'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)' + ) as ( + handle: number, + kind: string, + dbPath: string, + sql: string, + outJson: WcdbVoidOut + ) => number + } catch { + this.wcdbExecQuery = null + } + + try { + this.wcdbGetGroupMembers = lib.func( + 'int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)' + ) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetGroupMembers = null + } + + try { + this.wcdbOpenMessageCursor = lib.func( + 'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)' + ) as ( + handle: number, + username: string, + batchSize: number, + ascending: number, + beginTimestamp: number, + endTimestamp: number, + cursorOut: WcdbHandleOut + ) => number + this.wcdbFetchMessageBatch = lib.func( + 'int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)' + ) as (handle: number, cursor: number, outJson: WcdbVoidOut, outHasMore: [number]) => number + this.wcdbCloseMessageCursor = lib.func( + 'int32 wcdb_close_message_cursor(int64 handle, int64 cursor)' + ) as (handle: number, cursor: number) => number + } catch { + this.wcdbOpenMessageCursor = null + this.wcdbFetchMessageBatch = null + this.wcdbCloseMessageCursor = null + } + + try { + this.wcdbGetVoiceData = lib.func( + 'int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)' + ) as ( + handle: number, + sessionId: string, + createTime: number, + localId: number, + svrId: bigint, + candidatesJson: string, + outHex: WcdbVoidOut + ) => number + } catch { + this.wcdbGetVoiceData = null + } + + try { + this.wcdbResolveImageHardlink = lib.func( + 'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)' + ) as (handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbResolveImageHardlink = null + } + + try { + this.wcdbGetEmoticonCdnUrl = lib.func( + 'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)' + ) as (handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number + } catch { + console.warn('[WCDB4] wcdb_get_emoticon_cdn_url symbol unavailable') + this.wcdbGetEmoticonCdnUrl = null + } + } + + private initProtection(lib: KoffiLibrary, libDir: string): void { + const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as ( + resourcePath: string + ) => number + + const resourceRoots = Array.from( + new Set([ + libDir, + path.dirname(libDir), + process.env.WCDB_RESOURCES_PATH || '', + path.join(process.resourcesPath || process.cwd(), 'resources'), + process.resourcesPath || process.cwd(), + path.join(process.cwd(), 'resources') + ]) + ) + + let lastCode = -1 + for (const resourceRoot of resourceRoots) { + try { + lastCode = initProtection(resourceRoot) + if (lastCode === 0) return + } catch { + // Try next candidate. + } + } + + console.warn( + `InitProtection 返回 ${lastCode},继续尝试 wcdb_init/open; tried=${resourceRoots.join(' | ')}` + ) + } + + private findNativeLibrary(): string { + const libName = + process.platform === 'darwin' + ? 'libwcdb_api.dylib' + : process.platform === 'linux' + ? 'libwcdb_api.so' + : 'wcdb_api.dll' + const platformDir = process.platform === 'darwin' ? 'macos' : process.platform + const resourcesPath = process.resourcesPath || process.cwd() + const candidates = [ + process.env.WCDB_DLL_PATH, + path.join(resourcesPath, 'resources', platformDir, libName), + path.join(resourcesPath, 'resources', libName), + path.join(process.cwd(), 'resources', platformDir, libName), + path.join(process.cwd(), 'resources', libName) + ].filter(Boolean) as string[] + + const found = candidates.find((candidate) => fs.existsSync(candidate)) + if (!found) { + throw new Error(`找不到 WCDB native 库: ${candidates.join(', ')}`) + } + return found + } + + private findSessionDb(): string { + const direct = path.join(this.dbStoragePath, 'session', 'session.db') + if (fs.existsSync(direct)) return direct + return this.findFile(this.dbStoragePath, 'session.db') || '' + } + + private findEmoticonDb(): string { + const candidates = [ + path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'), + path.join(this.dbStoragePath, 'emotion', 'emoticon.db'), + path.join(this.accountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'), + path.join(this.accountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db') + ] + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate + } + return this.findFile(this.dbStoragePath, 'emoticon.db') || '' + } + + private findFile(dir: string, filename: string, depth = 0): string | null { + if (!fs.existsSync(dir) || depth > 5) return null + + const entries = fs.readdirSync(dir) + for (const entry of entries) { + const fullPath = path.join(dir, entry) + if (entry.toLowerCase() === filename.toLowerCase() && fs.statSync(fullPath).isFile()) { + return fullPath + } + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry) + if (fs.statSync(fullPath).isDirectory()) { + const found = this.findFile(fullPath, filename, depth + 1) + if (found) return found + } + } + + return null + } + + private callJson(call: (handle: number, outJson: WcdbVoidOut) => number): T { + const handle = this.ensureHandle() + const outJson: WcdbVoidOut = [null] + const result = call(handle, outJson) + if (result !== 0 || !outJson[0]) { + throw new Error(`WCDB 调用失败,错误码: ${result}`) + } + + try { + const json = this.koffi!.decode(outJson[0], 'char', -1) + return JSON.parse(json) as T + } finally { + this.wcdbFreeString?.(outJson[0]) + } + } + + private ensureHandle(): number { + if (!this.handle) throw new Error('微信 4.0 数据库未打开') + return this.handle + } + + private normalizeSession(row: Record): Wcdb4Session { + const username = this.pickString(row, [ + 'username', + 'user_name', + 'userName', + 'usrName', + 'UsrName', + 'talker', + 'talker_id', + 'talkerId', + 'sessionId', + 'session_id' + ]) + const nickname = this.pickString(row, [ + 'nickname', + 'nickName', + 'displayName', + 'display_name', + 'remark', + 'name' + ]) + return { username, nickname, raw: row } + } + + private normalizeMessage(row: Record): Wcdb4Message { + const contentRaw = this.pickValue(row, [ + 'message_content', + 'messageContent', + 'content', + 'msg_content', + 'msgContent', + 'WCDB_CT_message_content' + ]) + const compressRaw = this.pickValue(row, [ + 'compress_content', + 'compressContent', + 'compressed_content', + 'msg_compress_content', + 'msgCompressContent', + 'WCDB_CT_compress_content', + 'WCDB_CT_compressContent' + ]) + const content = this.decodeMessageContent(contentRaw, compressRaw) + const sender = this.pickString(row, [ + 'sender_username', + 'senderUsername', + 'sender', + 'fromUsername', + 'from_username', + 'WCDB_CT_sender_username' + ]) + const createTime = this.pickNumber(row, [ + 'create_time', + 'createTime', + 'msg_create_time', + 'msgCreateTime', + 'time', + 'WCDB_CT_create_time' + ]) + const localId = this.pickString(row, [ + 'local_id', + 'localId', + 'msg_local_id', + 'msgLocalId', + 'mesLocalID', + 'id' + ]) + const messageType = this.pickString(row, [ + 'local_type', + 'localType', + 'message_type', + 'messageType', + 'msg_type', + 'msgType', + 'type', + 'WCDB_CT_local_type' + ]) + const isSend = this.pickBoolean(row, [ + 'computed_is_send', + 'computedIsSend', + 'is_send', + 'isSend', + 'mesDes', + 'WCDB_CT_is_send' + ]) + + return { + mesLocalID: localId || `${createTime}-${crypto.randomUUID()}`, + mesDes: isSend ? 0 : 1, + messageType: messageType || '1', + msgCreateTime: String(createTime), + msgContent: content, + sender, + senderNickname: sender ? this.displayNameCache.get(sender) : undefined, + senderAvatar: sender ? this.avatarCache.get(sender) : undefined, + raw: row + } + } + + private hydrateDisplayNames(usernames: string[]): void { + if (!this.wcdbGetDisplayNames) return + const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) + if (missing.length === 0) return + + try { + const rows = this.callJson | Record[]>( + (handle, outJson) => this.wcdbGetDisplayNames!(handle, JSON.stringify(missing), outJson) + ) + this.readStringMap(rows, [ + 'nickname', + 'displayName', + 'display_name', + 'remark', + 'name' + ]).forEach((name, username) => this.displayNameCache.set(username, name)) + } catch { + // Names are optional; usernames are still enough to load chats. + } + } + + private hydrateAvatarUrls(usernames: string[]): void { + const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) + if (missing.length === 0) return + + if (this.wcdbGetAvatarUrls) { + try { + const rows = this.callJson | Record[]>( + (handle, outJson) => this.wcdbGetAvatarUrls!(handle, JSON.stringify(missing), outJson) + ) + this.readStringMap(rows, [ + 'avatarUrl', + 'avatar_url', + 'headImgUrl', + 'm_nsHeadImgUrl', + 'big_head_img_url', + 'small_head_img_url' + ]).forEach((avatar, username) => this.avatarCache.set(username, avatar)) + } catch { + // Try the contact database below. + } + } + + const stillMissing = missing.filter((username) => !this.avatarCache.has(username)) + if (stillMissing.length === 0) return + + try { + this.readContactAvatarUrls(stillMissing).forEach((avatar, username) => + this.avatarCache.set(username, avatar) + ) + } catch { + // Avatars are optional. + } + } + + private readContactAvatarUrls(usernames: string[]): Map { + const result = new Map() + if (!this.wcdbExecQuery || usernames.length === 0) return result + + const inList = this.uniq(usernames) + .map((username) => `'${username.replace(/'/g, "''")}'`) + .join(',') + if (!inList) return result + + const sql = `SELECT * FROM contact WHERE username IN (${inList})` + const rows = this.callJson[]>((handle, outJson) => + this.wcdbExecQuery!(handle, 'contact', '', sql, outJson) + ) + + if (!Array.isArray(rows)) return result + + for (const row of rows) { + const username = this.pickString(row, ['username', 'user_name', 'userName']) + const avatar = this.pickString(row, [ + 'big_head_img_url', + 'bigHeadImgUrl', + 'bigHeadUrl', + 'big_head_url', + 'small_head_img_url', + 'smallHeadImgUrl', + 'smallHeadUrl', + 'small_head_url', + 'head_img_url', + 'headImgUrl', + 'avatar_url', + 'avatarUrl' + ]) + if (username && avatar) result.set(username, avatar) + } + + return result + } + + private readStringMap( + rows: Record | Record[], + valueKeys: string[] + ): Map { + const result = new Map() + + if (Array.isArray(rows)) { + for (const row of rows) { + const username = this.pickString(row, ['username', 'userName', 'user_name', 'm_nsUsrName']) + const value = this.pickString(row, valueKeys) + if (username && value) result.set(username, value) + } + return result + } + + for (const [username, value] of Object.entries(rows || {})) { + if (username && value) result.set(username, String(value)) + } + + return result + } + + private pickString(row: Record, keys: string[]): string { + for (const key of keys) { + const value = this.pickValue(row, [key]) + if (typeof value === 'string' && value.trim()) return value.trim() + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + } + return '' + } + + private pickValue(row: Record, 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 + } + + private decodeMessageContent(messageContent: unknown, compressContent: unknown): string { + const compressed = this.decodeMaybeCompressed(compressContent) + if (compressed) return compressed + return this.decodeMaybeCompressed(messageContent) + } + + private decodeMaybeCompressed(raw: unknown): string { + if (raw === null || raw === undefined) return '' + if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw) + if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw)) + if (Array.isArray(raw)) return this.decodeBinaryContent(Buffer.from(raw)) + + if (typeof raw === 'number') return Number.isFinite(raw) ? String(raw) : '' + if (typeof raw !== 'string') { + const data = (raw as { data?: unknown })?.data + if (Array.isArray(data)) return this.decodeBinaryContent(Buffer.from(data)) + return '' + } + + const trimmed = raw.trim() + if (!trimmed) return '' + if (/^[0-9]+$/.test(trimmed)) return trimmed + + if (trimmed.length > 16 && this.looksLikeHex(trimmed)) { + try { + const decoded = this.decodeBinaryContent(Buffer.from(trimmed, 'hex')) + if (decoded) return decoded + } catch { + // Fall back to the original string below. + } + } + + if (trimmed.length > 16 && this.looksLikeBase64(trimmed)) { + try { + const decoded = this.decodeBinaryContent(Buffer.from(trimmed, 'base64')) + if (decoded) return decoded + } catch { + // Fall back to the original string below. + } + } + + return trimmed + } + + private decodeBinaryContent(data: Buffer): string { + if (data.length === 0) return '' + + try { + if (data.length >= 4 && data.readUInt32LE(0) === 0xfd2fb528) { + const fzstd = nodeRequire('fzstd') as { decompress: (input: Buffer) => Uint8Array } + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } + } catch { + return '' + } + + const decoded = data.toString('utf-8') + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2 && this.isMostlyReadableText(decoded)) { + return decoded.replace(/\uFFFD/g, '') + } + return '' + } + + private looksLikeHex(value: string): boolean { + return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value) + } + + private looksLikeBase64(value: string): boolean { + if (value.length % 4 !== 0) return false + return /^[A-Za-z0-9+/]+={0,2}$/.test(value) + } + + private isMostlyReadableText(value: string): boolean { + if (!value) return false + const readable = Array.from(value).filter((char) => { + const code = char.charCodeAt(0) + return code === 0x09 || code === 0x0a || code === 0x0d || code >= 0x20 + }).length + return readable / value.length > 0.85 + } + + private pickNumber(row: Record, keys: string[]): number { + for (const key of keys) { + const value = this.pickValue(row, [key]) + const parsed = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN + if (Number.isFinite(parsed)) { + return parsed > 1e12 ? Math.floor(parsed / 1000) : Math.floor(parsed) + } + } + return 0 + } + + private pickBoolean(row: Record, keys: string[]): boolean { + for (const key of keys) { + const value = this.pickValue(row, [key]) + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value === 1 + if (typeof value === 'string') return value === '1' || value.toLowerCase() === 'true' + } + return false + } + + private uniq(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))) + } + + private normalizeTimestamp(input: number): number { + if (!input || input <= 0) return 0 + const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input) + return Math.min(Math.max(normalized, 0), 2147483647) + } +} diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index cee1ece..febfa1d 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -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() + private wcdb4Client: Wcdb4Client | null = null + private chatMd5ToUsername = new Map() + 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 { + if (this.wcdb4Client) { + const groupContacts: Record = {} + 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 { + if (this.wcdb4Client) { + const members: Record = {} + 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 { + if (this.wcdb4Client) { + const username = this.chatMd5ToUsername.get(userMd5) + if (!username || !username.endsWith('@chatroom')) return {} + + const members: Record = {} + 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++) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 69ba294..6efb173 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + initDb: (key: string) => Promise getContacts: (filter?: string) => Promise getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise search: (keyword: string) => Promise 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 + 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 }> } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index c173e66..e625aad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) { diff --git a/src/renderer/index.html b/src/renderer/index.html index a629951..fd18c64 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 268ada7..073dd32 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 860d531..6b45be3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; -} \ No newline at end of file +} + +.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; +} diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 0d91ffe..1efd0c4 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -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 = ({ const messagesEndRef = useRef(null) const imageContainerRef = useRef(null) const [generatedImage, setGeneratedImage] = useState(null) - const [showAvatar, setShowAvatar] = useState(false) - - const [colWidths, setColWidths] = useState([150, 100, 180, 400]) - const [resizingColIndex, setResizingColIndex] = useState(null) - const startXRef = useRef(0) - const startWidthRef = useRef(0) + const [previewImage, setPreviewImage] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({
-
- - - - - - - - - - - {visibleMessages.map((msg) => ( - - - - - - - ))} - -
- 发送者 -
startResizing(0, e)} /> -
- 类型 -
startResizing(1, e)} /> -
- 时间 -
startResizing(2, e)} /> -
内容
- {msg.from} - - {msg.type} - - {msg.datetime} - - {showAvatar && msg?.img && ( - +
+ {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 ( +
+ {!isMine && showAvatar && ( +
+ {avatarSrc ? ( + {displayName} + ) : ( + (displayName || '?').charAt(0) )} -
- {(msg.name || contact.m_nsNickName) && ( -
- {isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'} - {isGroupChat ? (msg.name ? ':' : '') : ':'} -
- )} -
- {msg.content} -
-
-
+
+ )} +
+ {!isMine && isGroupChat &&
{displayName}
} +
+ {isVoice && msg.sessionId ? ( + + ) : isImage && msg.contentData && msg.contentData.type === 'image' ? ( + + ) : isRichMedia && msg.contentData ? ( + + ) : ( +
{msg.content}
+ )} +
+
+ {msg.datetime} + {msg.type} +
+
+ {isMine && showAvatar && ( +
+ {avatarSrc ? 我 : '我'} +
+ )} + + ) + })} {filteredMessages.length > displayLimit && (
)} + {previewImage && ( +
+
e.stopPropagation()}> +
+
+ 图片查看 + + {Math.round(imageScale * 100)}% + + + + + +
+ +
+
+ 图片预览 +
+
+
+ )} + {/* AI Settings Modal */} {showSettingsModal && (
setShowSettingsModal(false)}> diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx new file mode 100644 index 0000000..f2ce6f9 --- /dev/null +++ b/src/renderer/src/components/ImageBubble.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 => { + event.stopPropagation() + if (imageUrl) { + await window.api.copyImage(imageUrl) + alert('图片已复制') + } + } + + const handleClick = (): void => { + if (imageUrl) { + onImageClick?.(imageUrl) + } + } + + if (loading) { + return ( +
+
加载中...
+
+ ) + } + + if (error) { + return ( +
+
图片未加载
+
+ ) + } + + if (!imageUrl) { + return ( +
+
🖼
+
加载图片中
+
+ ) + } + + return ( +
+ 图片 +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx new file mode 100644 index 0000000..6b36e68 --- /dev/null +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -0,0 +1,222 @@ +import { ParsedContent } from '../../../shared/types' +import { useEffect, useState } from 'react' +import type { JSX, MouseEvent } from 'react' + +const stickerDataUrlCache = new Map() + +interface RichMessageBubbleProps { + contentData: ParsedContent +} + +export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element { + switch (contentData.type) { + case 'location': + return + case 'card': + return + case 'share': + return + case 'voip': + return + case 'sticker': + return + case 'quote': + return + case 'unknown': + return ( +
{(contentData as { raw?: string }).raw || '[未知消息]'}
+ ) + default: + return
[不支持的消息类型]
+ } +} + +function LocationBubble({ + data +}: { + data: Extract +}): 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 ( +
+
📍
+
+
{locationText}
+ {label && poiname && label !== poiname &&
{label}
} + {hasCoords && ( +
+ {lat.toFixed(6)}, {lng.toFixed(6)} +
+ )} +
+
+ ) +} + +function CardBubble({ data }: { data: Extract }): JSX.Element { + const { username, nickname } = data + + const handleCopy = (e: MouseEvent): void => { + e.stopPropagation() + navigator.clipboard.writeText(username) + } + + return ( +
+
{(nickname || username).charAt(0).toUpperCase()}
+
+
{nickname || '未知'}
+
+ {username} +
+
+
+ ) +} + +function ShareBubble({ data }: { data: Extract }): JSX.Element { + const { title, des, url, appname } = data + + const handleClick = (): void => { + if (url) { + window.open(url, '_blank') + } + } + + const urlHost = getUrlHost(url) + + return ( +
+ {appname &&
{appname}
} +
{title || '链接'}
+ {des &&
{des}
} + {urlHost &&
{urlHost}
} +
+ ) +} + +function VoipBubble({ data }: { data: Extract }): 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 ( +
+ {isVideo ? '📹' : '📞'} + + {status} + {duration ? ` ${formatDuration(duration)}` : ''} + +
+ ) +} + +function StickerBubble({ + data +}: { + data: Extract +}): 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 ( +
+ 表情包 +
+ ) + } + + if (loading) { + return ( +
+
表情包加载中...
+
+ ) + } + + return ( +
+
{error ? '表情包未缓存' : '表情包'}
+ {md5 &&
MD5: {md5}
} +
+ ) +} + +function QuoteBubble({ data }: { data: Extract }): JSX.Element { + const quotedText = data.quotedContent || data.content || '[引用消息]' + const replyText = data.content || data.title || '' + const quotedSender = data.quotedSender || data.sender || '' + + return ( +
+
+ {quotedSender && {quotedSender}} + {quotedText} +
+ {replyText &&
{replyText}
} +
+ ) +} + +function getUrlHost(url?: string): string { + if (!url) return '' + try { + return new URL(url).hostname + } catch { + return url + } +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 12534c6..e5f521a 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -48,7 +48,13 @@ export const Sidebar: React.FC = ({ className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`} onClick={() => onSelectContact(contact)} > -
{contact.m_nsNickName.charAt(0)}
+
+ {contact.avatar ? ( + {contact.m_nsNickName} + ) : ( + (contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0) + )} +
{contact.m_nsNickName}
@@ -98,12 +104,16 @@ export const Sidebar: React.FC = ({ {isGroupsExpanded ? '▼' : '▶'} 群聊 ({groups.length})
{isGroupsExpanded && groups.map(renderContactItem)} + {isGroupsExpanded && groups.length === 0 &&
暂无群聊
} {/* 联系人部分 */}
setIsContactsExpanded(!isContactsExpanded)}> {isContactsExpanded ? '▼' : '▶'} 联系人 ({users.length})
{isContactsExpanded && users.map(renderContactItem)} + {isContactsExpanded && users.length === 0 && ( +
暂无联系人
+ )} {/*
window.location.reload()}> diff --git a/src/renderer/src/components/VoicePlayer.tsx b/src/renderer/src/components/VoicePlayer.tsx new file mode 100644 index 0000000..3b50639 --- /dev/null +++ b/src/renderer/src/components/VoicePlayer.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [audioDuration, setAudioDuration] = useState(undefined) + const [shouldAutoPlay, setShouldAutoPlay] = useState(false) + const audioRef = useRef(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 ( +
+ + 加载中... +
+ ) + } + + if (error && !audioUrl) { + return ( +
+ + [语音] +
+ ) + } + + return ( +
+ {isPlaying ? '⏸' : '▶'} + + {formatDuration(audioDuration)} +
+ ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9239808..8a60c3a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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