Compare commits

...
10 Commits
Author SHA1 Message Date
电摇小子andClaude Opus 4.7 4bba8bd871 refactor: 移除微信 3.0 解密路径, 统一走 4.0 WCDB
- wechat-db.ts 删除 SQLCipher 回退、connectDb、getUser、tryOpenWechat4 等 3.0 专属逻辑
- image-decrypt-service.ts 删除 decryptDatV3 与 version === 0 分支
- index.ts 去除 wcdb4Client 冗余空值检查
- 移除 better-sqlite3-multiple-ciphers 依赖
- README 版本前置改为 4.0+

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 10:37:51 +08:00
电摇小子 30c47af1f9 fix: 修复 macOS 打包版 WCDB 初始化 2026-07-03 10:05:50 +08:00
电摇小子 77aa74dd92 style: 修改样式, 去除日志 2026-07-02 10:24:31 +08:00
电摇小子 f96ada906c feat: 实现退群监控 2026-06-30 16:04:01 +08:00
电摇小子andClaude Opus 4.7 a50e903e55 chore: bump version to 2.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 14:39:52 +08:00
电摇小子 5a87a10d12 feat: 添加微信数据库实时监控,聊天记录自动刷新 2026-06-30 14:36:45 +08:00
电摇小子 10dc74e2a1 feat: 支持自动获取并安全保存数据库密钥 2026-06-30 10:12:25 +08:00
电摇小子 c00d581d9e feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出
- 支持日报时间范围和消息类型筛选
- 支持群成员及当前用户群昵称解析
- 移除消息加载更多并优化图片懒加载
- 增强头像处理与敏感密钥日志保护
2026-06-29 16:43:01 +08:00
电摇小子 f6f8bc33f9 feat: 支持微信 4.0 数据库解密与富媒体消息查看
- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式
- 支持图片解密及图片预览、缩放、旋转和拖动
- 支持语音解密与播放
- 支持表情包、引用、分享、名片、位置及通话消息解析
- 支持联系人和群聊真实头像
- 优化群聊、联系人分类及排序
- 修复复合消息类型和压缩消息内容解析
- 完善原生解密库打包及数据库错误提示
2026-06-29 14:51:45 +08:00
电摇小子 7196adb84c chore: 去除release脚本 2026-06-01 11:36:04 +08:00
40 changed files with 7014 additions and 781 deletions
+9 -2
View File
@@ -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=
-59
View File
@@ -1,59 +0,0 @@
name: Build & Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: macos-latest
strategy:
matrix:
platform: [x64, arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: '7'
- name: Install Dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Build DMG
run: pnpm run build:mac:${{ matrix.platform }}
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.platform }}
path: out/mac-*/WechatExplorer-*.dmg
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: dmg-*
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*
generate_release_notes: true
+2 -1
View File
@@ -1,8 +1,9 @@
node_modules
dist
out
docs/
.env
.DS_Store
.eslintcache
*.log*
.omc
.omc
+60 -16
View File
@@ -3,11 +3,38 @@
MAC系统 获取微信聊天记录 AI一键生成群聊总结
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
## 项目说明
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
## ✨ 功能特性
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
- **全局搜索**: 快速搜索聊天内容。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
- **实用信息与资源**: 提取分享的链接、资源等信息。
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
- **有趣对话或金句**: 收录群内的精彩对话。
- **问题与解答**: 整理群内的问答内容。
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
- **词云/关键词**: 可视化展示群聊关键词。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 📸 预览
<img src="./public/example1.png" alt="预览图1" />
<img src="./public/example2.png" alt="预览图2" />
<img src="./public/example3.png" alt="预览图3" />
### 群聊总结长图
<img src="./public/example1.png" alt="总结图片" />
### 软件主页面
<img src="./public/example2.png" alt="软件主页面" />
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
@@ -18,28 +45,44 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
`系统设置 -> 隐私与安全性 -> 仍要打开`
## ✨ 功能特性
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
- **全局搜索**: 快速搜索聊天内容。
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
## 🚀 快速开始
### 使用前置要求
- **微信版本**: 推荐使用微信 **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 +90,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)
+16 -6
View File
@@ -1,5 +1,6 @@
appId: com.electron.app
productName: wechatexplorer
appId: com.wechatexplorer.app
productName: WechatExplorer
afterPack: scripts/after-pack.cjs
directories:
buildResources: build
files:
@@ -14,6 +15,11 @@ extraMetadata:
main: out/main/index.js
asarUnpack:
- resources/**
extraResources:
- from: resources
to: resources
filter:
- '**/*'
win:
executableName: wechatexplorer
nsis:
@@ -24,10 +30,14 @@ nsis:
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
# The bundled WCDB bridge accepts Electron as its internal host name. The
# public app name and bundle identifier remain WechatExplorer-specific.
CFBundleName: Electron
CFBundleDisplayName: WechatExplorer
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
+12 -19
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "1.1.0",
"version": "2.0.1",
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
"keywords": [
"wechat",
@@ -21,25 +21,27 @@
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac:x64": "electron-vite build && electron-builder --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --mac --arm64",
"release:mac": "electron-vite build && electron-builder --mac --x64 --arm64 --publish always",
"build:linux": "electron-vite build && electron-builder --linux"
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"better-sqlite3-multiple-ciphers": "^12.5.0",
"fs-extra": "^11.3.2",
"html-to-image": "^1.11.13",
"openai": "^6.10.0"
"fzstd": "^0.1.1",
"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",
@@ -63,14 +65,5 @@
"electron",
"esbuild"
]
},
"build": {
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"mac": {
"target": [
"dmg",
"zip"
]
}
}
}
+37 -130
View File
@@ -10,12 +10,12 @@ 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
'@types/react-dom': ^19.2.3
'@vitejs/plugin-react': ^5.1.1
better-sqlite3-multiple-ciphers: ^12.5.0
electron: ^39.2.6
electron-builder: ^26.0.12
electron-vite: ^5.0.0
@@ -24,26 +24,30 @@ specifiers:
eslint-plugin-react-hooks: ^7.0.1
eslint-plugin-react-refresh: ^0.4.24
fs-extra: ^11.3.2
html-to-image: ^1.11.13
fzstd: ^0.1.1
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
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@39.2.6
'@electron-toolkit/utils': 4.0.0_electron@39.2.6
better-sqlite3-multiple-ciphers: 12.5.0
fs-extra: 11.3.2
html-to-image: 1.11.13
fzstd: 0.1.1
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 +858,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]
@@ -1566,32 +1576,20 @@ packages:
/base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/baseline-browser-mapping/2.9.5:
resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
hasBin: true
dev: true
/better-sqlite3-multiple-ciphers/12.5.0:
resolution: {integrity: sha512-bYbiV0TdCIQFqlI122+wIFnc3yXWHyz8A/026Y/YfiA3vKcC0wLnHqqociNehpFmbRSowuY3Ko8SyUhBXI9+4A==}
engines: {node: 20.x || 22.x || 23.x || 24.x}
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
dev: false
/bindings/1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
dependencies:
file-uri-to-path: 1.0.0
dev: false
/bl/4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
dev: true
/boolean/3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
@@ -1635,6 +1633,7 @@ packages:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/builder-util-runtime/9.3.1:
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
@@ -1760,10 +1759,6 @@ packages:
supports-color: 7.2.0
dev: true
/chownr/1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
dev: false
/chownr/2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@@ -1935,11 +1930,6 @@ packages:
dependencies:
mimic-response: 3.1.0
/deep-extend/0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
dev: false
/deep-is/0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
@@ -1978,6 +1968,7 @@ packages:
/detect-libc/2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dev: true
/detect-node/2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
@@ -2516,11 +2507,6 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/expand-template/2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
dev: false
/exponential-backoff/3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
dev: true
@@ -2584,10 +2570,6 @@ packages:
flat-cache: 4.0.1
dev: true
/file-uri-to-path/1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
dev: false
/filelist/1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
dependencies:
@@ -2640,10 +2622,6 @@ packages:
mime-types: 2.1.35
dev: true
/fs-constants/1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
dev: false
/fs-extra/10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -2717,6 +2695,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'}
@@ -2771,10 +2753,6 @@ packages:
get-intrinsic: 1.3.0
dev: true
/github-from-package/0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
dev: false
/glob-parent/6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -2928,10 +2906,6 @@ packages:
lru-cache: 6.0.0
dev: true
/html-to-image/1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
dev: false
/http-cache-semantics/4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3008,6 +2982,7 @@ packages:
/ieee754/1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore/5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
@@ -3051,10 +3026,7 @@ packages:
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
/ini/1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
dev: false
dev: true
/internal-slot/1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
@@ -3387,6 +3359,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
@@ -3558,6 +3534,7 @@ packages:
/minimist/1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: true
/minipass-collect/1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
@@ -3623,10 +3600,6 @@ packages:
yallist: 4.0.0
dev: true
/mkdirp-classic/0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
dev: false
/mkdirp/1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -3642,10 +3615,6 @@ packages:
hasBin: true
dev: true
/napi-build-utils/2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
dev: false
/natural-compare/1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
@@ -3660,6 +3629,7 @@ packages:
engines: {node: '>=10'}
dependencies:
semver: 7.7.3
dev: true
/node-addon-api/1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
@@ -3908,25 +3878,6 @@ packages:
source-map-js: 1.2.1
dev: true
/prebuild-install/7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.85.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
dev: false
/prelude-ls/1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3994,16 +3945,6 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
/rc/1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
dev: false
/react-dom/19.2.1_react@19.2.1:
resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
peerDependencies:
@@ -4043,6 +3984,7 @@ packages:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
dev: true
/reflect.getprototypeof/1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
@@ -4182,6 +4124,7 @@ packages:
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: true
/safe-push-apply/1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
@@ -4335,16 +4278,9 @@ packages:
engines: {node: '>=14'}
dev: true
/simple-concat/1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
dev: false
/simple-get/4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
/silk-wasm/3.7.1:
resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==}
engines: {node: '>=16.11.0'}
dev: false
/simple-update-notifier/2.0.0:
@@ -4509,6 +4445,7 @@ packages:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: true
/strip-ansi/6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
@@ -4524,11 +4461,6 @@ packages:
ansi-regex: 6.2.2
dev: true
/strip-json-comments/2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
dev: false
/strip-json-comments/3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -4561,26 +4493,6 @@ packages:
'@pkgr/core': 0.2.9
dev: true
/tar-fs/2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
dev: false
/tar-stream/2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
dev: false
/tar/6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
@@ -4640,12 +4552,6 @@ packages:
typescript: 5.9.3
dev: true
/tunnel-agent/0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
dependencies:
safe-buffer: 5.2.1
dev: false
/type-check/0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4784,6 +4690,7 @@ packages:
/util-deprecate/1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
/verror/1.10.1:
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+479
View File
@@ -0,0 +1,479 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{REPORT_TITLE}}</title>
<style>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}
html {
width: 430px;
scrollbar-width: none;
}
body {
margin: 0;
width: 430px;
background: #f3f5f7;
color: #1f2933;
font-family:
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.report {
width: 430px;
margin: 0 auto;
padding: 22px 14px 34px;
}
.hero,
.card,
.section {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.hero {
padding: 20px;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.hero h1 {
font-size: 23px;
line-height: 1.2;
margin: 0 0 8px;
font-weight: 900;
}
.sub {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.avatar-grid {
width: 58px;
height: 58px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
flex: 0 0 auto;
}
.avatar-grid img,
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 16px;
}
.stat {
background: #f7faf9;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
}
.stat b {
display: block;
font-size: 18px;
color: #07a352;
}
.stat span {
font-size: 11px;
color: #667085;
}
.section {
margin-top: 14px;
padding: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 900;
margin-bottom: 12px;
}
.section-title::before {
content: '';
width: 5px;
height: 20px;
border-radius: 99px;
background: #07c160;
}
.card {
padding: 14px;
margin-top: 10px;
box-shadow: none;
border: 1px solid #edf0f2;
}
.topic-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.topic-title-row h3 {
font-size: 16px;
line-height: 1.35;
margin: 0;
font-weight: 850;
}
.heat,
.tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: #eef8f2;
color: #07a352;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.hot {
background: #fff4e5;
color: #d46b08;
}
.blue {
background: #eef5ff;
color: #1677ff;
}
.red {
background: #fff1f0;
color: #ff4d4f;
}
.topic-meta {
margin-top: 6px;
color: #8a94a6;
font-size: 12px;
}
.card p {
font-size: 13px;
line-height: 1.65;
margin: 10px 0 0;
}
.participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
background: #f6f8fa;
border-radius: 999px;
padding: 3px 8px 3px 3px;
}
.person-chip img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.person-chip b {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.keywords span {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
}
.resource {
padding: 11px 12px;
background: #f7f8fa;
border-radius: 12px;
margin-top: 8px;
font-size: 13px;
line-height: 1.55;
}
.resource b {
color: #1677ff;
}
.important-card {
display: flex;
gap: 10px;
background: #f7faf9;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.important-card > .avatar {
width: 36px;
height: 36px;
flex: 0 0 auto;
}
.important-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #667085;
}
.important-meta b {
color: #1f2933;
}
.important-text {
font-size: 13px;
line-height: 1.55;
margin-top: 5px;
}
.important-note {
margin-top: 8px;
padding: 7px 9px;
border-left: 3px solid #07c160;
background: #fff;
border-radius: 8px;
color: #07a352;
font-size: 12px;
line-height: 1.45;
}
.chat-block {
background: #f0f2f5;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.chat-msg {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.chat-name {
font-size: 11px;
color: #667085;
margin-bottom: 4px;
}
.chat-bubble {
background: #fff;
border-radius: 4px 12px 12px 12px;
padding: 9px 10px;
font-size: 13px;
line-height: 1.5;
}
.quote-note {
background: #fff8e1;
border-radius: 10px;
padding: 9px 10px;
margin-top: 10px;
color: #8a5a00;
font-size: 12px;
line-height: 1.5;
}
.qa-card {
background: #f8fafc;
border-radius: 14px;
padding: 12px;
margin-top: 10px;
}
.qa-card b {
display: block;
color: #1f2933;
margin-bottom: 5px;
}
.qa-card div {
font-size: 13px;
line-height: 1.55;
color: #485465;
}
.bar-row {
display: grid;
grid-template-columns: 82px 1fr;
gap: 8px;
align-items: center;
margin-top: 9px;
font-size: 12px;
}
.bar {
height: 10px;
background: #edf1f5;
border-radius: 999px;
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: #07c160;
border-radius: 999px;
}
.rank {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 0;
border-bottom: 1px solid #eef0f2;
}
.rank img {
width: 30px;
height: 30px;
border-radius: 50%;
object-fit: cover;
}
.rank b {
font-size: 13px;
}
.rank span {
margin-left: auto;
color: #8a94a6;
font-size: 12px;
}
.cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cloud-tags span {
padding: 6px 10px;
border-radius: 999px;
background: #f2f4f7;
color: #485465;
font-weight: 800;
}
.cloud-tags .xl {
font-size: 20px;
color: #07a352;
background: #e9f8ef;
}
.cloud-tags .lg {
font-size: 17px;
color: #1677ff;
background: #eef5ff;
}
.cloud-tags .md {
font-size: 15px;
color: #d46b08;
background: #fff4e5;
}
.footer {
padding: 16px 4px 0;
color: #98a2b3;
text-align: center;
font-size: 11px;
line-height: 1.8;
}
.muted {
color: #8a94a6;
}
.empty-section {
display: none;
}
@media (max-width: 430px) {
html,
body {
width: 100%;
}
.report {
width: 100%;
padding-left: 12px;
padding-right: 12px;
}
.stats {
gap: 6px;
}
.stat b {
font-size: 16px;
}
}
</style>
</head>
<body>
<main class="report">
<header class="hero">
<div class="hero-top">
<div>
<h1>{{GROUP_NAME}}日报</h1>
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
</div>
<div class="avatar-grid">{{HERO_AVATARS}}</div>
</div>
<div class="stats">
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
</div>
</header>
<section class="section topics">
<div class="section-title">今日讨论热点</div>
{{TOPIC_CARDS}}
</section>
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
{{IMPORTANT_MESSAGES}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
{{QUOTE_BLOCKS}}
</section>
<section class="section qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<div class="card">
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
话唠榜 TOP5(基于已读取记录估算)
</div>
{{RANK_ITEMS}}
</div>
<div class="card">
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
</section>
<footer class="footer">
数据来源:微信群聊记录<br />
生成时间:{{GENERATED_AT}}<br />
{{FOOTER_NOTE}}
</footer>
</main>
</body>
</html>
BIN
View File
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
const { existsSync, renameSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const COMPATIBILITY_NAME = 'Electron'
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
const appPath = path.join(context.appOutDir, `${productName}.app`)
const contentsPath = path.join(appPath, 'Contents')
const sourceExecutable = path.join(contentsPath, 'MacOS', productName)
const targetExecutable = path.join(contentsPath, 'MacOS', COMPATIBILITY_NAME)
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
if (!existsSync(targetExecutable)) {
throw new Error(`Missing Electron main executable: ${sourceExecutable}`)
}
const appPlistPath = path.join(contentsPath, 'Info.plist')
setPlistValue(appPlistPath, 'CFBundleExecutable', COMPATIBILITY_NAME)
setPlistValue(appPlistPath, 'CFBundleName', COMPATIBILITY_NAME)
const frameworksPath = path.join(contentsPath, 'Frameworks')
for (const suffix of HELPER_SUFFIXES) {
const sourceName = `${productName} Helper${suffix}`
const targetName = `${COMPATIBILITY_NAME} Helper${suffix}`
const sourceBundle = path.join(frameworksPath, `${sourceName}.app`)
const targetBundle = path.join(frameworksPath, `${targetName}.app`)
if (existsSync(sourceBundle)) renameSync(sourceBundle, targetBundle)
if (!existsSync(targetBundle)) {
throw new Error(`Missing Electron helper bundle: ${sourceBundle}`)
}
const sourceExecutable = path.join(targetBundle, 'Contents', 'MacOS', sourceName)
const targetExecutable = path.join(targetBundle, 'Contents', 'MacOS', targetName)
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
if (!existsSync(targetExecutable)) {
throw new Error(`Missing Electron helper executable: ${sourceExecutable}`)
}
const plistPath = path.join(targetBundle, 'Contents', 'Info.plist')
setPlistValue(plistPath, 'CFBundleExecutable', targetName)
setPlistValue(plistPath, 'CFBundleName', targetName)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
export interface StoredKeyResult {
success: boolean
key?: string
error?: string
}
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
export const isValidDatabaseKey = (value: string): boolean =>
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
export class DatabaseKeyStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
}
async load(): Promise<StoredKeyResult> {
try {
if (!(await fs.pathExists(this.filePath))) return { success: true }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
const encrypted = await fs.readFile(this.filePath)
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' }
return { success: true, key }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async save(rawKey: string): Promise<StoredKeyResult> {
const key = normalizeDatabaseKey(rawKey)
if (!isValidDatabaseKey(key)) {
return { success: false, error: '密钥必须是 64 位十六进制字符' }
}
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
try {
await fs.ensureDir(path.dirname(this.filePath))
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
await fs.chmod(this.filePath, 0o600)
return { success: true, key }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
async clear(): Promise<{ success: boolean; error?: string }> {
try {
await fs.remove(this.filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+296
View File
@@ -0,0 +1,296 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import {
GroupReportExportRequest,
GroupReportExportResult,
ReportHeat
} from '../shared/group-report'
const TEMPLATE_NAME = 'mobile_daily_report.html'
const escapeHtml = (value: unknown): string =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
const sanitizeFileName = (value: string): string =>
value
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim() || '未命名群聊'
const hashName = (name: string): number => {
let hash = 0
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return hash
}
const fallbackAvatar = (name: string): string => {
const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
const imageMimeType = (contentType: string | null, source: string): string => {
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
const extension = path.extname(source).toLowerCase()
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
if (extension === '.gif') return 'image/gif'
return 'image/jpeg'
}
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
try {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source, {
headers: {
'User-Agent': 'Mozilla/5.0 WechatExplorer',
Referer: 'https://weixin.qq.com/'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const mime = imageMimeType(response.headers.get('content-type'), source)
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
}
const localPath = source.startsWith('file://') ? new URL(source) : source
const buffer = await fs.readFile(localPath)
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
} catch (error) {
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
return fallbackAvatar(name)
}
}
const templatePath = (): string => {
const candidates = [
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
]
const found = candidates.find((candidate) => fs.existsSync(candidate))
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
return found
}
const heatClass = (heat: ReportHeat): string => {
if (heat === '高') return 'hot'
if (heat === '低') return 'blue'
return ''
}
const replacePlaceholder = (html: string, key: string, value: string): string =>
html.replaceAll(`{{${key}}}`, value)
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
const { report, metadata } = request
const avatarNames = new Set<string>(metadata.heroParticipants)
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
report.quotes.forEach((quote) =>
quote.messages.forEach((message) => avatarNames.add(message.sender))
)
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
const avatars = new Map<string, string>()
await Promise.all(
Array.from(avatarNames).map(async (name) => {
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
})
)
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
const heroNames = metadata.heroParticipants.slice(0, 4)
while (heroNames.length < 4) heroNames.push(metadata.groupName)
const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
.join('')
const topicCards = report.topics
.map(
(topic) => `<div class="card topic-card">
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}</span></div>
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
<p>${escapeHtml(topic.summary)}</p>
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
<div class="participants">${topic.participants
.slice(0, 5)
.map(
(name) =>
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
)
.join('')}</div>
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
</div>`
)
.join('')
const resourceItems = report.resources
.map(
(resource) =>
`<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`
)
.join('')
const importantMessages = report.importantMessages
.map(
(message) => `<div class="important-card">
<img class="avatar" src="${avatar(message.sender)}" alt="">
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
</div>`
)
.join('')
const quoteBlocks = report.quotes
.map(
(quote) =>
`<div class="chat-block">${quote.messages
.map(
(
message
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
</div></div>`
)
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
)
.join('')
const qaCards = report.qa
.map(
(item) =>
`<div class="qa-card"><b>Q${escapeHtml(item.question)}</b><div>A${escapeHtml(item.answer)}${item.answerer ? `${escapeHtml(item.answerer)}` : ''}</div></div>`
)
.join('')
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
const heatBars = report.analytics.topicHeat
.map(
(item) =>
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
)
.join('')
const rankItems = report.analytics.topSpeakers
.slice(0, 5)
.map(
(speaker, index) =>
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
)
.join('')
const cloudTags = report.keywords
.slice(0, 15)
.map(
(word, index) =>
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
)
.join('')
let html = await fs.readFile(templatePath(), 'utf8')
const values: Record<string, string> = {
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
GROUP_NAME: escapeHtml(metadata.groupName),
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan),
TOPIC_COUNT: String(report.topics.length),
TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
RESOURCE_ITEMS: resourceItems,
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
IMPORTANT_MESSAGES: importantMessages,
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
QUOTE_BLOCKS: quoteBlocks,
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
QA_CARDS: qaCards,
HEAT_BARS: heatBars,
RANK_ITEMS: rankItems,
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
CLOUD_TAGS: cloudTags,
GENERATED_AT: escapeHtml(metadata.generatedAt),
FOOTER_NOTE: escapeHtml(metadata.footerNote)
}
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
return html
}
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
const reportWindow = new BrowserWindow({
show: false,
width: 430,
height: 800,
frame: false,
backgroundColor: '#f3f5f7',
webPreferences: { sandbox: true }
})
try {
await reportWindow.loadFile(htmlPath)
await reportWindow.webContents.executeJavaScript(`Promise.all([
document.fonts.ready,
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}))
])`)
reportWindow.webContents.debugger.attach('1.3')
const metrics = (await reportWindow.webContents.debugger.sendCommand(
'Page.getLayoutMetrics'
)) as { cssContentSize: { width: number; height: number } }
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
const height = Math.ceil(metrics.cssContentSize.height)
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
'Page.captureScreenshot',
{
format: 'png',
captureBeyondViewport: true,
fromSurface: true,
clip: { x: 0, y: 0, width, height, scale: 1 }
}
)) as { data: string }
const png = Buffer.from(screenshot.data, 'base64')
if (png.length < 1000) throw new Error('生成的日报图片为空')
await fs.writeFile(pngPath, png)
return `data:image/png;base64,${screenshot.data}`
} finally {
if (reportWindow.webContents.debugger.isAttached()) {
reportWindow.webContents.debugger.detach()
}
reportWindow.destroy()
}
}
export const exportGroupReport = async (
request: GroupReportExportRequest
): Promise<GroupReportExportResult> => {
try {
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
const htmlPath = path.join(outputDir, `${baseName}.html`)
const pngPath = path.join(outputDir, `${baseName}.png`)
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
return { success: true, htmlPath, pngPath, imageDataUrl }
} catch (error) {
console.error('[GroupReport] export failed:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
+512
View File
@@ -0,0 +1,512 @@
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,
'aesKey present:',
!!this.aesKey
)
let decrypted: Buffer
if (version === 1) {
console.log('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key)
} else if (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)
decrypted = this.decryptDatV4(datPath, key)
} else {
console.log('[ImageDecrypt] unsupported dat version:', version)
return null
}
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
}
/**
* V4 - AES + XOR
*/
private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer {
const bytes = readFileSync(inputPath)
if (bytes.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
// 对齐 AES 数据到 16 字节边界
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
// 解密 AES 数据
const aesData = data.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted)
}
// 解密 XOR 数据
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData: Buffer
let xoredData: Buffer
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
xoredData[i] = xorData[i] ^ this.xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
/**
*
*/
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 4) return null
const SIGNATURES: Record<string, Buffer> = {
'.jpg': Buffer.from([0xff, 0xd8, 0xff]),
'.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
'.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
'.bmp': Buffer.from([0x42, 0x4d]),
'.webp': Buffer.from([0x52, 0x49, 0x46, 0x46])
}
for (const [ext, sig] of Object.entries(SIGNATURES)) {
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
return ext
}
}
return null
}
private getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp'
}
return mimeTypes[ext] || 'image/jpeg'
}
private normalizeDatBase(value: string): string {
const lower = String(value || '')
.trim()
.toLowerCase()
if (!lower) return ''
const file = lower.split('/').pop()?.split('\\').pop() || lower
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
}
private buildPreferredDatNames(baseName: string): string[] {
const base = this.normalizeDatBase(baseName)
if (!base) return []
return [
`${base}_h.dat`,
`${base}.dat`,
`${base}_hd.dat`,
`${base}_c.dat`,
`${base}_t.dat`,
`${base}.thumb.dat`,
`${base}_thumb.dat`
]
}
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
const ordered = allowThumbnail
? variants
: variants.filter((name) => !this.isThumbnailName(name))
for (const variant of ordered) {
const candidate = join(actualDir, variant)
if (existsSync(candidate)) return candidate
}
return inputPath
}
private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase()
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
}
private unwrapWxgf(buffer: Buffer): Buffer {
if (
buffer.length < 20 ||
buffer[0] !== 0x77 ||
buffer[1] !== 0x78 ||
buffer[2] !== 0x67 ||
buffer[3] !== 0x66
) {
return buffer
}
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
return buffer.subarray(i)
}
if (
buffer[i] === 0x89 &&
buffer[i + 1] === 0x50 &&
buffer[i + 2] === 0x4e &&
buffer[i + 3] === 0x47
) {
return buffer.subarray(i)
}
}
return buffer
}
private uniq(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
private bytesToInt32(bytes: Buffer): number {
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
private compareBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
private strictRemovePadding(buffer: Buffer): Buffer {
if (buffer.length === 0) return buffer
const lastByte = buffer[buffer.length - 1]
if (lastByte <= 16 && lastByte > 0) {
const paddingLength = lastByte
let valid = true
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
if (buffer[i] !== lastByte) {
valid = false
break
}
}
if (valid) {
return buffer.subarray(0, buffer.length - paddingLength)
}
}
return buffer
}
}
+257 -11
View File
@@ -3,25 +3,64 @@ 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'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac'
let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const keyServiceMac = new KeyServiceMac()
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
// In dev, matching the host app name avoids failing the native protection gate.
app.setName('WechatExplorer')
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
function createWindow(): void {
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1200,
height: 670,
width: 1400,
height: 800,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
@@ -52,6 +91,7 @@ function createWindow(): void {
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(() => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron')
@@ -67,11 +107,47 @@ app.whenReady().then(() => {
ipcMain.handle('db:init', (_, key: string) => {
try {
wechatDb = new WechatDb(key)
return true
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const nextWechatDb = new WechatDb(key)
wechatDb?.close()
wechatDb = nextWechatDb
const wcdb4Client = nextWechatDb.getWcdb4Client()
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
imageDecryptService = null
return { success: true, monitoring }
} catch (error) {
console.error('Failed to init DB:', error)
return false
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey)
})
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event) => {
const result = await keyServiceMac.autoGetDbKey((message) => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
})
if (!result.success || !result.key) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
@@ -86,12 +162,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 +202,33 @@ 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()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
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) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} 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,18 +253,102 @@ app.whenReady().then(() => {
}
}
// 解析富媒体消息内容
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) {
content = '[语音消息]'
}
return {
id: msg.mesLocalID || Math.random().toString(),
from: msg.mesDes === 1 ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
type: MSG_TYPE_DICT[msgType] || msg.messageType,
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
img: img,
name: name
name: name,
sessionId: username,
localId: localId,
createTime: createTime,
contentData: contentData
}
})
})
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => {
if (!wechatDb) return null
const wcdb4Client = wechatDb.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return {
roomId,
memberCount: members.length,
members
}
})
ipcMain.handle('db:search', (_, keyword: string) => {
if (!wechatDb) return null
return wechatDb.searchAllMessages(keyword)
@@ -225,6 +403,69 @@ app.whenReady().then(() => {
}
})
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
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 () {
@@ -242,3 +483,8 @@ app.on('window-all-closed', () => {
app.quit()
}
})
app.on('before-quit', () => {
wechatDb?.close()
wechatDb = null
})
+160
View File
@@ -0,0 +1,160 @@
import { app } from 'electron'
import { execFile } from 'child_process'
import fs from 'fs-extra'
import path from 'path'
import { promisify } from 'util'
import { isValidDatabaseKey } from './database-key-store'
const execFileAsync = promisify(execFile)
export interface DatabaseKeyResult {
success: boolean
key?: string
error?: string
code?: string
}
export class KeyServiceMac {
private getHelperPath(): string {
const candidates = app.isPackaged
? [
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
path.join(process.resourcesPath, 'xkey_helper')
]
: [
path.join(process.cwd(), 'resources', 'xkey_helper'),
path.join(app.getAppPath(), 'resources', 'xkey_helper')
]
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
if (!helperPath) throw new Error('找不到 xkey_helper')
return helperPath
}
private async isSipEnabled(): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled')
} catch {
return false
}
}
private async getWeChatPid(): Promise<number> {
const commands: [string, string[]][] = [
['/usr/bin/pgrep', ['-x', 'WeChat']],
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
]
for (const [command, args] of commands) {
try {
const { stdout } = await execFileAsync(command, args)
const pids = stdout
.split(/\r?\n/)
.map((value) => Number.parseInt(value.trim(), 10))
.filter((value) => Number.isFinite(value) && value > 0)
if (pids.length) return Math.max(...pids)
} catch {
// Try the next process lookup strategy.
}
}
throw new Error('未找到微信主进程,请先启动并登录微信')
}
private parseHelperOutput(output: string): DatabaseKeyResult {
const payloads: Record<string, unknown>[] = []
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
try {
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
} catch {
// Ignore helper progress that is not JSON.
}
}
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
if (!isValidDatabaseKey(rawKey)) {
const errorPayload = payloads.find((item) => typeof item.result === 'string')
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
const code = parsedError?.[1]?.toUpperCase()
const detail = parsedError?.[2]?.trim() || ''
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
return {
success: false,
code,
error:
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
}
}
if (code === 'SCAN_FAILED') {
return {
success: false,
code,
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
}
}
return {
success: false,
code,
error: rawError || '密钥工具未返回有效的 64 位密钥'
}
}
return { success: true, key: rawKey }
}
async autoGetDbKey(
onStatus?: (message: string) => void,
timeoutMs = 60_000
): Promise<DatabaseKeyResult> {
if (process.platform !== 'darwin') {
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
}
if (await this.isSipEnabled()) {
return {
success: false,
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
}
}
try {
onStatus?.('正在查找微信进程...')
const pid = await this.getWeChatPid()
const helperPath = this.getHelperPath()
const waitMs = Math.max(30_000, timeoutMs)
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
onStatus?.('正在请求管理员授权...')
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSeconds}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "OK::" & outText',
'on error errMsg number errNum',
'return "ERR::" & errNum & "::" & errMsg',
'end try'
]
onStatus?.('授权后请保持微信已登录并活动...')
const { stdout } = await execFileAsync(
'/usr/bin/osascript',
scriptLines.flatMap((line) => ['-e', line]),
{ timeout: waitMs + 20_000 }
)
const output = String(stdout).trim()
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
if (output.startsWith('ERR::')) {
return {
success: false,
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
}
}
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
return result
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}
+588
View File
@@ -0,0 +1,588 @@
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; raw?: 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 parseSystemMessage(normalized)
default:
return { type: 'text', content: normalized }
}
}
function parseSystemMessage(content: string): ParsedContent {
const stripped = stripChatroomPrefix(content)
const decoded = decodeXmlEntities(stripped)
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
if (delChatroomMemberText) {
return {
type: 'system',
content: normalizeSystemText(delChatroomMemberText),
raw: content
}
}
const plainText =
extractXmlNodeText(decoded, 'plain') ||
extractXmlNodeText(decoded, 'text') ||
extractXmlNodeText(decoded, 'title') ||
extractXmlValue(decoded, 'plain') ||
extractXmlValue(decoded, 'text') ||
extractXmlValue(decoded, 'title') ||
''
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded))
return {
type: 'system',
content: normalized || '[系统消息]',
raw: content
}
}
function parseImageMessage(content: string): ParsedContent {
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
let aeskey =
extractXmlAttribute(content, 'img', 'aeskey') || extractXmlValue(content, 'aeskey') || undefined
const encrypVerStr =
extractXmlAttribute(content, 'img', 'encrypver') || extractXmlValue(content, 'encrypver') || '0'
let datName = ''
// 如果 XML 格式解析失败,尝试 JSON 格式
if (!md5) {
try {
const json = JSON.parse(content)
// 可能是引用消息格式 { type: "...", content: "md5", ... }
if (
json.content &&
typeof json.content === 'string' &&
/^[a-f0-9]{32}$/i.test(json.content)
) {
md5 = json.content
} else if (json.md5 && typeof json.md5 === 'string') {
md5 = json.md5
}
if (json.datName && typeof json.datName === 'string') {
datName = json.datName
}
if (json.imageDatName && typeof json.imageDatName === 'string') {
datName = json.imageDatName
}
// 尝试从其他字段获取 aeskey
if (!aeskey && json.aeskey) {
aeskey = json.aeskey
}
if (!aeskey && json.aeskey_v2) {
aeskey = json.aeskey_v2
}
} catch {
// 不是 JSON 格式
}
}
const encrypVer = parseInt(encrypVerStr, 10)
if (!md5 && !datName) {
return { type: 'unknown', raw: content }
}
return { type: 'image', md5: md5 || undefined, datName: datName || undefined, aeskey, encrypVer }
}
function parseStickerMessage(content: string): ParsedContent {
// 表情包消息可能包含 md5 或 url
const md5 =
extractXmlAttribute(content, 'emoji', 'md5') ||
extractXmlValue(content, 'md5') ||
extractXmlAttribute(content, 'sticker', 'md5') ||
extractLooseHexMd5(content) ||
''
const url = decodeXmlUrl(
extractXmlValue(content, 'url') ||
extractXmlAttribute(content, 'emoji', 'cdnurl') ||
extractXmlAttribute(content, 'emoji', 'url') ||
extractXmlAttribute(content, 'emoji', 'thumburl') ||
extractLooseAttribute(content, 'cdnurl') ||
extractLooseAttribute(content, 'url') ||
extractLooseAttribute(content, 'thumburl') ||
''
)
const thumbUrl = decodeXmlUrl(
extractXmlAttribute(content, 'emoji', 'thumburl') || extractLooseAttribute(content, 'thumburl')
)
const encryptUrl = decodeXmlUrl(
extractXmlAttribute(content, 'emoji', 'encrypturl') ||
extractLooseAttribute(content, 'encrypturl')
)
const aeskey =
extractXmlAttribute(content, 'emoji', 'aeskey') ||
extractLooseAttribute(content, 'aeskey') ||
undefined
if (!md5 && !url && !thumbUrl && !encryptUrl) {
return { type: 'unknown', raw: content }
}
return {
type: 'sticker',
md5,
url: url || thumbUrl || undefined,
thumbUrl: thumbUrl || undefined,
encryptUrl: encryptUrl || undefined,
aeskey
}
}
export function parseStickerMessageFromRow(
row: Record<string, unknown>,
content: string
): ParsedContent {
const supplementalPayload = [
content,
pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']),
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']),
decodeSupplementalPayload(
pickRowString(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info'
])
),
decodeSupplementalPayload(pickRowString(row, ['reserved0', 'Reserved0', 'WCDB_CT_reserved0']))
]
.filter(Boolean)
.join('\n')
const directMd5 = normalizeMd5(pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']))
const directUrl = decodeXmlUrl(
String(
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']) || ''
)
)
const parsed = parseStickerMessage(supplementalPayload)
if (parsed.type === 'sticker') {
return {
...parsed,
md5: parsed.md5 || directMd5,
url: parsed.url || directUrl || undefined
}
}
if (directMd5 || directUrl) {
return {
type: 'sticker',
md5: directMd5,
url: directUrl || undefined
}
}
return parsed
}
function parseCardMessage(content: string): ParsedContent {
const username =
extractXmlValue(content, 'username') || extractXmlValue(content, 'cardUsername') || ''
const nickname =
extractXmlValue(content, 'nickname') || extractXmlValue(content, 'cardNickname') || ''
const avatarUrl =
extractXmlValue(content, 'avatarUrl') ||
extractXmlValue(content, 'smallHeadImgUrl') ||
undefined
if (!username && !nickname) {
return { type: 'unknown', raw: content }
}
return { type: 'card', username, nickname, avatarUrl }
}
function parseLocationMessage(content: string): ParsedContent {
const poiname = extractXmlValue(content, 'poiname') || extractXmlValue(content, 'poiName') || ''
const label = extractXmlValue(content, 'label') || ''
const latStr =
extractXmlAttribute(content, 'location', 'x') ||
extractXmlAttribute(content, 'location', 'latitude') ||
'0'
const lngStr =
extractXmlAttribute(content, 'location', 'y') ||
extractXmlAttribute(content, 'location', 'longitude') ||
'0'
const lat = parseFloat(latStr)
const lng = parseFloat(lngStr)
if (!poiname && lat === 0 && lng === 0) {
return { type: 'unknown', raw: content }
}
return { type: 'location', poiname, label, lat, lng }
}
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
if (appMsgType === '57' || content.includes('<refermsg>')) {
const quote = parseQuoteMessage(content)
const title = extractXmlValue(content, 'title') || undefined
return {
type: 'quote',
title,
content: title,
quotedContent: quote.content || '[引用消息]',
quotedSender: quote.sender,
quotedType: quote.type
}
}
const title = extractXmlValue(content, 'title') || ''
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
const url = extractXmlValue(content, 'url') || ''
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
const typeVal = extractXmlValue(content, 'type') || ''
if (!title && !url) {
return { type: 'unknown', raw: content }
}
return { type: 'share', title, des, url, appname, typeVal }
}
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) return {}
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
const sender =
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
undefined
const referContent = extractXmlValue(referMsgXml, 'content')
const referType = extractXmlValue(referMsgXml, 'type')
switch (referType) {
case '1':
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
case '3':
return { sender, content: '[图片]', type: referType }
case '34':
return { sender, content: '[语音]', type: referType }
case '43':
return { sender, content: '[视频]', type: referType }
case '47':
return { sender, content: '[表情]', type: referType }
case '49':
return {
sender,
content: extractXmlValue(referMsgXml, 'title') || '[分享消息]',
type: referType
}
default:
return {
sender,
content: sanitizeQuotedContent(referContent) || '[引用消息]',
type: referType
}
}
}
function extractAppMsgType(content: string): string {
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (!appmsgMatch) return extractXmlValue(content, 'type')
const inner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
return typeMatch?.[1]?.trim() || ''
}
function sanitizeQuotedContent(content: string): string {
const decoded = String(content || '')
.replace(/^wxid_[^:\n]+:\s*/i, '')
.trim()
if (/^(wxid_[\w-]+|[a-z][a-z0-9_-]{5,})$/i.test(decoded)) return ''
return decoded
}
function stripChatroomPrefix(content: string): string {
return String(content || '')
.replace(/^[0-9a-z_-]+@chatroom:\s*/i, '')
.replace(/^wxid_[^:\n]+:\s*/i, '')
.trim()
}
function normalizeSystemText(content: string): string {
return String(content || '')
.replace(/\s+/g, ' ')
.replace(/\s+([,.;!?])/g, '$1')
.trim()
}
function parseVoipMessage(content: string): ParsedContent {
const roomTypeStr = extractXmlValue(content, 'room_type')
const msg = extractXmlValue(content, 'msg') || ''
const durationStr = extractXmlValue(content, 'duration') || '0'
const roomType = roomTypeStr ? parseInt(roomTypeStr, 10) : 0
const duration = parseInt(durationStr, 10)
let status = msg
if (!status) {
status = roomType === 1 ? '[视频通话]' : '[语音通话]'
}
return { type: 'voip', duration, status, roomType }
}
function extractXmlValue(xml: string, tagName: string): string {
const patterns = [
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i'),
new RegExp(`${tagName}=["']([^"']*)["']`, 'i')
]
for (const pattern of patterns) {
const match = xml.match(pattern)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
const match = xml.match(pattern)
return match ? match[1].trim() : ''
}
function extractLooseAttribute(content: string, attrName: string): string {
const quoted = new RegExp(`${attrName}\\s*=\\s*["']([^"']+)["']`, 'i').exec(content)
if (quoted?.[1]) return quoted[1].trim()
const unquoted = new RegExp(`${attrName}\\s*=\\s*([^"']+?)(?=\\s|/|>)`, 'i').exec(content)
return unquoted?.[1]?.trim() || ''
}
function decodeXmlUrl(value: string): string {
const normalized = String(value || '')
.replace(/&amp;/g, '&')
.trim()
if (!normalized) return ''
if (!normalized.includes('%')) return normalized
try {
return decodeURIComponent(normalized)
} catch {
return normalized
}
}
function decodeXmlEntities(value: string): string {
return String(value || '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
function extractXmlNodeText(xml: string, tagName: string): string {
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
if (!match?.[1]) return ''
return normalizeSystemText(
decodeXmlEntities(
match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ')
)
)
}
function fallbackSystemText(xml: string): string {
return normalizeSystemText(
decodeXmlEntities(
String(xml || '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/<[^>]+>/g, ' ')
)
)
}
function extractDelChatroomMemberText(xml: string): string {
if (!/<sysmsg[^>]+delchatroommember/i.test(xml)) return ''
const plainMatch = /<plain[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/plain>/i.exec(xml)
if (plainMatch?.[1]) return plainMatch[1].trim()
const textMatch = /<text[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/text>/i.exec(xml)
if (textMatch?.[1]) return textMatch[1].trim()
return ''
}
function normalizeMd5(value: unknown): string | undefined {
const md5 = String(value || '')
.trim()
.toLowerCase()
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
}
function extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const match =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return normalizeMd5(match?.[1] || match?.[0])
}
function decodeSupplementalPayload(raw: unknown): string {
if (!raw) return ''
if (typeof raw === 'string' && !/^[a-fA-F0-9]+$/.test(raw.trim())) return raw.trim()
const buffer = decodePackedInfo(raw)
if (!buffer || buffer.length === 0) return ''
const decoded = buffer.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return Array.from(buffer)
.map((byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ' '))
.join('')
}
export function parseImageDatNameFromRow(row: Record<string, unknown>): string | undefined {
const packed = pickRowString(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info',
'WCDB_CT_PackedInfoData',
'WCDB_CT_PackedInfo'
])
const buffer = decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined
const printable = Array.from(buffer).map((byte) => (byte >= 0x20 && byte <= 0x7e ? byte : 0x20))
const text = Buffer.from(printable).toString('utf-8')
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
if (match?.[1]) return match[1].toLowerCase()
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
return hexMatch?.[1]?.toLowerCase()
}
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
const foundKey = Object.keys(row).find(
(candidate) => candidate.toLowerCase() === key.toLowerCase()
)
if (foundKey) return row[foundKey]
}
return undefined
}
function decodePackedInfo(raw: unknown): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
try {
return Buffer.from(trimmed, 'hex')
} catch {
// Try base64 below.
}
}
try {
return Buffer.from(trimmed, 'base64')
} catch {
// Unsupported packed_info encoding.
}
}
if (typeof raw === 'object' && raw && Array.isArray((raw as { data?: unknown }).data)) {
return Buffer.from((raw as { data: number[] }).data)
}
return null
}
+210
View File
@@ -0,0 +1,210 @@
import crypto from 'crypto'
import fs from 'fs-extra'
import http from 'http'
import https from 'https'
import os from 'os'
import path from 'path'
import { Wcdb4Client } from './wcdb4-client'
type StickerResult = { success: boolean; data?: string; error?: string }
const downloadCache = new Map<string, Promise<StickerResult>>()
export class StickerService {
private readonly cacheDir: string
constructor(private readonly wcdb4Client?: Wcdb4Client | null) {
this.cacheDir = path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
}
async resolveSticker(cdnUrl?: string, md5?: string): Promise<StickerResult> {
const normalizedMd5 = this.normalizeMd5(md5)
let url = String(cdnUrl || '').trim()
if (!url && normalizedMd5 && this.wcdb4Client) {
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
if (!url) {
console.warn(`[StickerService] emoticon CDN URL not found for md5=${normalizedMd5}`)
}
}
if (!url) {
return { success: false, error: '未找到表情包 CDN URL' }
}
const cacheKey = normalizedMd5 || crypto.createHash('md5').update(url).digest('hex')
const cached = await this.readCached(cacheKey)
if (cached) return { success: true, data: cached }
if (normalizedMd5 && this.wcdb4Client) {
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
if (wechatCached) return { success: true, data: wechatCached }
}
const pending = downloadCache.get(cacheKey)
if (pending) return pending
const task = this.downloadToDataUrl(url, cacheKey)
downloadCache.set(cacheKey, task)
try {
return await task
} finally {
downloadCache.delete(cacheKey)
}
}
private async readCached(cacheKey: string): Promise<string | null> {
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
for (const cacheDir of cacheDirs) {
for (const ext of extensions) {
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
if (!fs.existsSync(filePath)) continue
const buffer = await fs.readFile(filePath)
return this.toDataUrl(buffer, ext)
}
}
return null
}
private async readWechatEmoticonCache(md5: string): Promise<string | null> {
const accountRoot = this.wcdb4Client?.getAccountRoot()
if (!accountRoot) return null
const cacheRoot = path.join(accountRoot, 'cache')
if (!fs.existsSync(cacheRoot)) return null
const prefix = md5.slice(0, 2)
let months: string[] = []
try {
months = fs
.readdirSync(cacheRoot)
.filter((name) => /^\d{4}-\d{2}$/.test(name))
.sort()
.reverse()
} catch {
return null
}
for (const month of months) {
const filePath = path.join(cacheRoot, month, 'Emoticon', prefix, md5)
if (!fs.existsSync(filePath)) continue
const buffer = await fs.readFile(filePath)
const ext = this.detectExtension(buffer) || '.gif'
return this.toDataUrl(buffer, ext)
}
return null
}
private downloadToDataUrl(
url: string,
cacheKey: string,
redirectCount = 0
): Promise<StickerResult> {
return new Promise((resolve) => {
if (redirectCount > 5) {
resolve({ success: false, error: '表情包下载重定向过多' })
return
}
const client = url.startsWith('https:') ? https : http
const request = client.get(
url,
{
headers: {
'User-Agent': 'Mozilla/5.0 MicroMessenger WechatExplorer',
Referer: 'https://weixin.qq.com/'
}
},
(response) => {
const redirectUrl = response.headers.location
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
const nextUrl = new URL(redirectUrl, url).toString()
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
return
}
if (response.statusCode !== 200) {
console.warn(
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
)
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
return
}
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', async () => {
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) {
resolve({ success: false, error: '表情包内容为空' })
return
}
const ext = this.detectExtension(buffer) || this.getExtFromUrl(url) || '.gif'
try {
await fs.ensureDir(this.cacheDir)
await fs.writeFile(path.join(this.cacheDir, `${cacheKey}${ext}`), buffer)
} catch {
// Cache is best effort; the data URL can still be displayed.
}
resolve({ success: true, data: this.toDataUrl(buffer, ext) })
})
}
)
request.on('error', (error) => resolve({ success: false, error: error.message }))
request.setTimeout(15000, () => {
request.destroy()
resolve({ success: false, error: '表情包下载超时' })
})
})
}
private detectExtension(buffer: Buffer): string | null {
if (buffer.length >= 6 && buffer.subarray(0, 3).toString('ascii') === 'GIF') return '.gif'
if (
buffer.length >= 8 &&
buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
)
return '.png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
return '.jpg'
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp'
}
return null
}
private getExtFromUrl(url: string): string | null {
try {
const ext = path.extname(new URL(url).pathname).toLowerCase()
return ['.gif', '.png', '.webp', '.jpg', '.jpeg'].includes(ext) ? ext : null
} catch {
return null
}
}
private toDataUrl(buffer: Buffer, ext: string): string {
const mimeTypes: Record<string, string> = {
'.gif': 'image/gif',
'.png': 'image/png',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg'
}
return `data:${mimeTypes[ext] || 'image/gif'};base64,${buffer.toString('base64')}`
}
private normalizeMd5(value?: string): string | undefined {
const md5 = String(value || '')
.trim()
.toLowerCase()
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
}
}
+162
View File
@@ -0,0 +1,162 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { Wcdb4Client } from './wcdb4-client'
export class VoiceService {
private wcdb4Client: Wcdb4Client
private voiceCache = new Map<string, string>()
constructor(wcdb4Client: Wcdb4Client) {
this.wcdb4Client = wcdb4Client
}
async resolveVoice(
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
): Promise<{ success: boolean; data?: string; error?: string }> {
const cacheKey = this.buildCacheKey(sessionId, localId, createTime)
const cached = this.voiceCache.get(cacheKey)
if (cached) {
console.log('[VoiceService] cache hit for', cacheKey)
return { success: true, data: cached }
}
const candidates = this.buildCandidates(sessionId)
console.log('[VoiceService] resolving voice:', { sessionId, localId, createTime, candidates })
const voiceResult = await this.wcdb4Client.getVoiceData(
sessionId,
createTime,
candidates,
localId,
svrId || 0
)
if (!voiceResult.success || !voiceResult.hex) {
console.log('[VoiceService] getVoiceData failed:', voiceResult.error)
return { success: false, error: voiceResult.error || '获取语音数据失败' }
}
console.log('[VoiceService] got hex data, length:', voiceResult.hex.length)
const silkData = this.decodeVoiceBlob(voiceResult.hex)
if (!silkData || silkData.length === 0) {
console.log('[VoiceService] decodeVoiceBlob failed, hex:', voiceResult.hex.substring(0, 100))
return { success: false, error: '语音数据为空' }
}
console.log('[VoiceService] silkData length:', silkData.length)
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
if (!pcmData || pcmData.length === 0) {
console.log('[VoiceService] decodeSilkToPcm failed')
return { success: false, error: 'Silk 解码失败' }
}
console.log('[VoiceService] pcmData length:', pcmData.length)
const wavData = this.createWavBuffer(pcmData, 24000)
console.log(
'[VoiceService] wavData length:',
wavData.length,
'base64 length:',
wavData.toString('base64').length
)
const base64Data = wavData.toString('base64')
this.voiceCache.set(cacheKey, base64Data)
return { success: true, data: base64Data }
}
private buildCacheKey(sessionId: string, localId: number, createTime: number): string {
return `${sessionId}-${localId}-${createTime}`
}
private buildCandidates(sessionId: string): string[] {
const candidates: string[] = [sessionId]
if (sessionId.endsWith('@chatroom')) {
candidates.push(sessionId.replace('@chatroom', ''))
}
return candidates
}
private decodeVoiceBlob(hex: string): Buffer | null {
try {
const hexClean = hex.replace(/\s+/g, '')
if (!/^[0-9a-fA-F]+$/.test(hexClean)) {
return null
}
return Buffer.from(hexClean, 'hex')
} catch {
return null
}
}
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
wasmPath = join(
process.resourcesPath,
'app.asar.unpacked',
'node_modules',
'silk-wasm',
'lib',
'silk.wasm'
)
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
console.error('[VoiceService] silk.wasm not found at:', wasmPath)
return null
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const silkWasm = require('silk-wasm')
if (!silkWasm || !silkWasm.decode) {
console.error('[VoiceService] silk-wasm module invalid')
return null
}
const result = await silkWasm.decode(silkData, sampleRate)
return Buffer.from(result.data)
} catch (e) {
console.error('[VoiceService] decodeSilkToPcm error:', e)
return null
}
}
private createWavBuffer(
pcmData: Buffer,
sampleRate: number = 24000,
channels: number = 1
): Buffer {
const pcmLength = pcmData.length
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcmLength, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20)
header.writeUInt16LE(channels, 22)
header.writeUInt32LE(sampleRate, 24)
header.writeUInt32LE(sampleRate * channels * 2, 28)
header.writeUInt16LE(channels * 2, 32)
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcmLength, 40)
return Buffer.concat([header, pcmData])
}
}
File diff suppressed because it is too large Load Diff
+82 -272
View File
@@ -1,14 +1,9 @@
import DatabaseConstructor from 'better-sqlite3-multiple-ciphers'
import fs from 'fs-extra'
import path from 'path'
import crypto from 'crypto'
import os from 'os'
type Database = import('better-sqlite3-multiple-ciphers').Database
import { Wcdb4Client } from './wcdb4-client'
export interface UserContact {
m_nsUsrName: string
nickname: string
avatar?: string
}
export interface WechatMessage {
@@ -26,6 +21,7 @@ export interface Contact {
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface GroupMemberInfo {
@@ -35,308 +31,122 @@ export interface GroupMemberInfo {
}
export class WechatDb {
private static WECHAT_DIR = path.join(
os.homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
)
private rawKey: string
private correctUserId: string | null = null
private chatDb: { name: string; db_number: string }[] | null = null
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
constructor(rawKey: string) {
this.rawKey = rawKey
console.log(`Initializing WechatDb with key: ${rawKey}`)
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
}
if (this.getUser()) {
console.log('User found, getting chat DB number')
this.chatDb = this.getChatDbNumber()
} else {
throw new Error('No valid user found or invalid key')
}
}
private connectDb(dbPath: string): Database | null {
const targetPath = path.resolve(dbPath)
if (!fs.existsSync(targetPath)) {
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
return null
}
try {
const db = new DatabaseConstructor(targetPath, {
// verbose: console.log
})
// 配置加密方案 (Cipher Scheme)
db.pragma("cipher='sqlcipher'")
db.pragma('cipher_page_size = 1024')
db.pragma('legacy=3')
db.pragma('kdf_iter = 64000')
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
const processedKey = this.processRawKey(this.rawKey)
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
db.pragma(`key = "x'${processedKey}'"`)
// 验证连接
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
return db
} catch (error) {
console.error(`❌ 连接失败:`, error)
const errString = String(error)
if (errString.includes('file is not a database')) {
console.error(
"💡 诊断建议:\n1. 请确认是否已安装 'better-sqlite3-multiple-ciphers'。\n2. 检查密钥是否正确(WeChat密钥与设备强绑定)。\n3. 尝试将页大小改为 4096 (db.pragma('cipher_page_size = 4096'))。"
)
} else if (errString.includes('HMAC')) {
console.error(
'💡 诊断建议:密钥可能正确,但HMAC算法或页大小不匹配。尝试调整 cipher_hmac_algorithm。'
)
}
return null
}
}
private processRawKey(key: string): string {
// 移除可能的 '0x' 前缀并修剪空格
return key.replace(/^0x/i, '').trim()
}
private getUser(): boolean {
const keyValuePath = path.join(WechatDb.WECHAT_DIR, 'KeyValue')
if (!fs.existsSync(keyValuePath)) return false
const contents = fs.readdirSync(keyValuePath)
const potentialUsers = contents.filter((name) => !name.startsWith('.'))
for (const user of potentialUsers) {
const dbPath = path.join(keyValuePath, user, 'KeyValue.db')
if (fs.existsSync(dbPath)) {
const db = this.connectDb(dbPath)
if (db) {
this.correctUserId = user
console.log(`Valid user found: ${user}`)
db.close()
return true
}
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client = new Wcdb4Client(rawKey)
client.open()
this.wcdb4Client = client
for (const table of client.getChatTables()) {
if (table.name.startsWith('Chat_')) {
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
}
}
return false
}
private getChatDbNumber(): { name: string; db_number: string }[] {
const chatDb: { name: string; db_number: string }[] = []
if (!this.correctUserId) return []
for (let i = 0; i < 10; i++) {
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', `msg_${i}.db`)
if (!fs.existsSync(dbPath)) continue
const db = this.connectDb(dbPath)
if (db) {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
.all() as { name: string }[]
for (const table of tables) {
chatDb.push({ name: table.name, db_number: `msg_${i}.db` })
}
db.close()
}
}
return chatDb
}
public getUserList(nicknameFilter?: string): UserContact[] {
if (!this.correctUserId) return []
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
const db = this.connectDb(dbPath)
if (!db) return []
let query = "SELECT m_nsUsrName, nickname FROM WCContact WHERE m_nsUsrName NOT LIKE 'gh_%'"
if (nicknameFilter) {
query += ` AND nickname LIKE '%${nicknameFilter}%'`
}
const results = db.prepare(query).all() as unknown as UserContact[]
db.close()
return results
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)
)
})
}
public getAllGroupContacts(): Record<string, string> {
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) return {}
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupContact').all() as {
m_nsUsrName: string
nickname: string
}[]
db.close()
const groupContacts: Record<string, string> = {}
for (const row of results) {
if (row.m_nsUsrName && row.nickname) {
const md5 = this.md5(row.m_nsUsrName)
groupContacts[md5] = row.nickname
for (const session of this.wcdb4Client.getSessions()) {
if (session.username.endsWith('@chatroom')) {
groupContacts[this.md5(session.username)] = session.nickname || session.username
}
}
return groupContacts
}
public getAllGroupMembers(): Record<string, string> {
if (!this.correctUserId) return {}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) return {}
// 查询 GroupMember 表
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupMember').all() as {
m_nsUsrName: string
nickname: string
}[]
db.close()
const groupMembers: Record<string, string> = {}
for (const row of results) {
if (row.m_nsUsrName && row.nickname) {
// 将 wxid (m_nsUsrName) 映射到昵称
groupMembers[row.m_nsUsrName] = row.nickname
const members: Record<string, string> = {}
for (const session of this.wcdb4Client.getSessions()) {
if (!session.username.endsWith('@chatroom')) continue
for (const member of this.wcdb4Client.getGroupMembers(session.username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
}
return groupMembers
return members
}
public getGroupMember(wxid: string): GroupMemberInfo | null {
if (!this.correctUserId) return null
public getGroupMembersForChat(userMd5: string): Record<string, string> {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username || !username.endsWith('@chatroom')) return {}
// 检查缓存
if (this.groupMemberCache.has(wxid)) {
return this.groupMemberCache.get(wxid) || null
const members: Record<string, string> = {}
for (const member of this.wcdb4Client.getGroupMembers(username)) {
if (member.m_nsUsrName) {
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
}
}
return members
}
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
const db = this.connectDb(dbPath)
if (!db) {
this.groupMemberCache.set(wxid, null)
return null
}
const result = db
.prepare(
'SELECT m_nsUsrName, nickname, m_nsHeadImgUrl FROM GroupMember WHERE m_nsUsrName = ?'
)
.get(wxid) as GroupMemberInfo
db.close()
// 写入缓存
this.groupMemberCache.set(wxid, result || null)
return result || null
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
if (!chatroomId) return null
return (
this.wcdb4Client
.getGroupMembers(chatroomId)
.find((member) => member.m_nsUsrName === wxid) || null
)
}
public getAllChatTables(): { name: string; db_number: string }[] {
return this.chatDb || []
return this.wcdb4Client.getChatTables()
}
public getMyAvatarUrl(): string | undefined {
return this.wcdb4Client.getMyAvatarUrl()
}
public getWcdb4Client(): Wcdb4Client {
return this.wcdb4Client
}
public close(): void {
this.wcdb4Client.close()
}
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
if (!this.chatDb || !this.correctUserId) return []
const tableName = `Chat_${userMd5}`
const tableInfo = this.chatDb.find((t) => t.name === tableName)
if (!tableInfo) {
console.error(`No table found for ${tableName}`)
return []
}
const dbPath = path.join(
WechatDb.WECHAT_DIR,
this.correctUserId,
'Message',
tableInfo.db_number
)
const db = this.connectDb(dbPath)
if (!db) return []
try {
let query = `SELECT * FROM ${tableName}`
const conditions: string[] = []
// console.log(startTime, endTime, 'time range')
if (startTime) {
conditions.push(`msgCreateTime >= ${startTime}`)
}
if (endTime) {
conditions.push(`msgCreateTime <= ${endTime}`)
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`
}
// 添加排序以确保存储正确的顺序
query += ` ORDER BY msgCreateTime ASC`
const results = db.prepare(query).all() as unknown as WechatMessage[]
db.close()
return results
} catch (e) {
console.error(`Error querying messages for ${tableName}:`, e)
db.close()
return []
}
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime).map((message) => ({
...message,
...message.raw
}))
}
public searchAllMessages(keyword: string): string | null {
if (!this.correctUserId) return null
for (let i = 0; i < 10; i++) {
const dbName = `msg_${i}.db`
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', dbName)
if (!fs.existsSync(dbPath)) continue
const db = this.connectDb(dbPath)
if (!db) continue
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
.all() as { name: string }[]
for (const table of tables) {
try {
// 首先检查 msgContent 列是否存在以避免错误?
// 或者像 Swift 代码中那样使用 try/catch "抑制错误"
const results = db
.prepare(`SELECT 1 FROM ${table.name} WHERE msgContent LIKE '%${keyword}%' LIMIT 1`)
.get()
if (results) {
console.log(`Found keyword in ${table.name} (${dbName})`)
db.close()
return table.name
}
} catch {
// 忽略没有 msgContent 的表
}
}
db.close()
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
}
public md5(str: string): string {
return crypto.createHash('md5').update(str).digest('hex')
return this.wcdb4Client.md5(str)
}
}
+69 -2
View File
@@ -1,19 +1,86 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
export type ParsedContent =
| { type: 'text'; content: string }
| { type: 'voice'; duration?: number }
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
| { type: 'voip'; duration?: number; status: string; roomType?: number }
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
| {
type: 'sticker'
md5?: string
url?: string
thumbUrl?: string
encryptUrl?: string
aeskey?: string
}
| {
type: 'quote'
title?: string
content?: string
sender?: string
quotedContent?: string
quotedSender?: string
quotedType?: string
}
| { type: 'system'; content: string }
| { type: 'unknown'; raw: string }
declare global {
interface Window {
electron: ElectronAPI
api: {
initDb: (key: string) => Promise<boolean>
initDb: (
key: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
getContacts: (filter?: string) => Promise<Contact[]>
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
} | null>
search: (keyword: string) => Promise<string | null>
aiChat: (
messages: { role: string; content: string }[],
options?: { apiKey?: string; model?: string }
options?: { apiKey?: string; model?: string; baseURL?: string }
) => Promise<{ success: boolean; data?: string; error?: string }>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
localId: number,
createTime: number,
svrId?: string | number
) => Promise<{ success: boolean; data?: string; error?: string }>
parseMessage: (content: string, messageType: number) => Promise<ParsedContent>
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
getSticker: (
cdnUrl?: string,
md5?: string
) => Promise<{ success: boolean; data?: string; error?: string }>
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
autoGetDbKey: () => Promise<{
success: boolean
key?: string
error?: string
code?: string
saved?: boolean
warning?: string
}>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
}
}
}
+32 -2
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { GroupReportExportRequest } from '../shared/group-report'
// 渲染器的自定义 API
const api = {
@@ -7,12 +8,41 @@ const api = {
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
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),
exportGroupReport: (request: GroupReportExportRequest) =>
ipcRenderer.invoke('report:export', request),
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
payload: { type: string; json: string }
): void => callback(payload)
ipcRenderer.on('wcdb-change', listener)
return () => ipcRenderer.removeListener('wcdb-change', listener)
},
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
callback(payload)
ipcRenderer.on('key:dbKeyStatus', listener)
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
}
}
if (process.contextIsolated) {
+1 -1
View File
@@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
/>
</head>
+324 -18
View File
@@ -3,6 +3,85 @@ import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { Contact, Message } from '../../shared/types'
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false
}
return true
}
type GroupSnapshot = {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
next: GroupSnapshot | null,
referenceMessages: Message[]
): Message[] => {
if (!previous || !next || previous.roomId !== next.roomId) return []
const previousMap = new Map(previous.members.map((member) => [member.wxid, member]))
const nextMap = new Map(next.members.map((member) => [member.wxid, member]))
const latestMessageTime = referenceMessages.reduce(
(max, message) => Math.max(max, message.createTime || 0),
0
)
const fallbackNow = Math.floor(Date.now() / 1000)
const events: Message[] = []
let offset = 1
for (const [wxid, member] of previousMap.entries()) {
if (nextMap.has(wxid)) continue
const name = formatGroupMemberName(member)
const eventTime = Math.max(latestMessageTime + offset, fallbackNow)
const eventDate = new Date(eventTime * 1000)
offset += 1
events.push({
id: `synthetic-leave:${next.roomId}:${wxid}:${eventTime}`,
from: 'system',
type: '系统消息',
datetime: eventDate.toLocaleString('zh-CN', { hour12: false }),
content: `${name} 退出了群聊`,
isSender: false,
createTime: eventTime
})
}
if (events.length) {
console.log(
`[GroupMonitor] synthetic leave detected roomId=${next.roomId} events=${events
.map((event) => event.content)
.join(' | ')}`
)
}
return events
}
const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
@@ -12,6 +91,35 @@ function App(): React.ReactElement {
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认为今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
React.useEffect(() => {
let active = true
void window.api.getSavedDbKey().then((result) => {
if (!active) return
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已加载安全保存的密钥')
setDbKeyStatusKind('success')
}
})
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
if (!active) return
setDbKeyStatus(message)
setDbKeyStatusKind('normal')
})
return () => {
active = false
unsubscribe()
}
}, [])
// useEffect(() => {
// if (import.meta.env.VITE_DB_KEY) {
@@ -23,12 +131,15 @@ 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) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
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)
@@ -36,6 +147,85 @@ function App(): React.ReactElement {
}
}
const logGroupSnapshot = React.useCallback(
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
try {
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as GroupSnapshot | null
if (!snapshot) {
return null
}
return snapshot
} catch (error) {
console.warn(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} failed:`, error)
return null
}
},
[]
)
const mergeSyntheticMessages = React.useCallback(
(contact: Contact | null, baseMessages: Message[], roomId?: string): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const resolvedRoomId = roomId || currentGroupSnapshotRef.current?.roomId
if (!resolvedRoomId) return baseMessages
const synthetic = syntheticGroupMessagesRef.current[resolvedRoomId] || []
return synthetic.length
? sortMessagesChronologically([...baseMessages, ...synthetic])
: baseMessages
},
[]
)
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal')
setShowMacKeyFaq(false)
try {
const result = await window.api.autoGetDbKey()
if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error')
} finally {
setIsFetchingDbKey(false)
}
}
const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey()
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已从剪贴板粘贴并安全保存')
setDbKeyStatusKind('success')
} else {
setDbKeyStatus(result.error || '粘贴并保存失败')
setDbKeyStatusKind('error')
}
}
const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey()
if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error')
return
}
setDbKey('')
setDbKeyStatus('已清除保存的密钥')
setDbKeyStatusKind('normal')
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
@@ -78,24 +268,104 @@ function App(): React.ReactElement {
setSelectedContact(contact)
const { startTime, endTime } = getDateRangeParams(dateRange)
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
setMessages(msgs)
const snapshot = await logGroupSnapshot(contact, 'select-contact')
currentGroupSnapshotRef.current = snapshot
setMessages(mergeSyntheticMessages(contact, msgs, snapshot?.roomId))
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
// 如果选择了联系人,则使用新范围重新加载消息
if (selectedContact) {
// 需要调用 handleSelectContact,但它需要一个联系人对象。
// 由于状态更新是异步的,可能需要使用状态中的当前联系人,
// 此函数内部 'selectedContact' 可从闭包中获得。
// 但是,需要确保 'dateRange' 已更新。
// 实际上,就在这里使用新范围手动触发获取。
const { startTime, endTime } = getDateRangeParams(range)
window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages)
window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => {
setMessages(mergeSyntheticMessages(selectedContact, nextMessages))
})
}
}
React.useEffect(() => {
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
let disposed = false
let refreshTimer: number | null = null
const contactMd5 = selectedContact.md5
const refreshCurrentConversation = async (): Promise<void> => {
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime
)
const latestSnapshot = await logGroupSnapshot(selectedContact, 'wcdb-change')
const syntheticEvents = buildSyntheticGroupMessages(
currentGroupSnapshotRef.current,
latestSnapshot,
latestMessages
)
if (latestSnapshot) {
currentGroupSnapshotRef.current = latestSnapshot
if (syntheticEvents.length) {
const existing = syntheticGroupMessagesRef.current[latestSnapshot.roomId] || []
const existingIds = new Set(existing.map((message) => message.id))
const appended = syntheticEvents.filter((message) => !existingIds.has(message.id))
if (appended.length) {
syntheticGroupMessagesRef.current[latestSnapshot.roomId] = [...existing, ...appended]
console.log(
`[GroupMonitor] merged synthetic messages roomId=${latestSnapshot.roomId} total=${syntheticGroupMessagesRef.current[latestSnapshot.roomId].length}`
)
if (!disposed) {
setMessages((current) =>
sortMessagesChronologically([
...current,
...appended.filter(
(message) =>
!current.some((existingMessage) => existingMessage.id === message.id)
)
])
)
}
}
}
}
const nextMessages = mergeSyntheticMessages(
selectedContact,
latestMessages,
latestSnapshot?.roomId
)
if (!disposed) {
setMessages((current) =>
areMessagesEquivalent(current, nextMessages) ? current : nextMessages
)
}
} catch (error) {
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
}
}
const unsubscribe = window.api.onWcdbChange(() => {
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
void refreshCurrentConversation()
}, MESSAGE_MONITOR_DEBOUNCE_MS)
})
return () => {
disposed = true
if (refreshTimer) window.clearTimeout(refreshTimer)
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
logGroupSnapshot,
mergeSyntheticMessages
])
const handleSearchContacts = (keyword: string): void => {
if (!keyword) {
setFilteredContacts(contacts)
@@ -144,16 +414,52 @@ function App(): React.ReactElement {
<div className="login-modal">
<div className="login-box">
<h2>Enter WeChat DB Key</h2>
<input
type="password"
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<div className="login-input-wrapper">
<input
type={showDbKey ? 'text' : 'password'}
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<button
type="button"
className="login-input-toggle"
onClick={() => setShowDbKey(!showDbKey)}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
{showDbKey ? '👁️' : '👁️‍🗨️'}
</button>
</div>
<button
className="login-btn login-btn-secondary"
onClick={handleAutoGetDbKey}
disabled={isFetchingDbKey}
>
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button>
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
</button>
<button className="login-btn" onClick={() => handleLogin()}>
Connect
</button>
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
</button>
{dbKeyStatus && (
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
)}
{showMacKeyFaq && (
<a
className="login-key-help-link"
href={MAC_KEY_FAQ_URL}
target="_blank"
rel="noreferrer"
>
macOS
</a>
)}
</div>
</div>
)
+831 -5
View File
@@ -133,6 +133,12 @@ body {
width: 12px;
}
.section-empty {
padding: 10px 14px 12px 27px;
color: #888;
font-size: 12px;
}
.contact-item {
padding: 10px;
display: flex;
@@ -159,6 +165,16 @@ body {
justify-content: center;
font-size: 12px;
color: #fff;
overflow: hidden;
flex-shrink: 0;
}
.contact-avatar img,
.message-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.contact-info {
@@ -215,6 +231,346 @@ body {
flex-direction: column;
}
.wechat-message-list {
padding: 18px 28px;
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 {
margin-bottom: 14px;
}
.wechat-message-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.wechat-message-row.mine {
justify-content: flex-end;
}
.wechat-system-message-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 2px 0 6px;
}
.wechat-system-message {
max-width: min(78%, 760px);
padding: 6px 12px;
border-radius: 999px;
background: rgba(227, 86, 86, 0.1);
color: #c84a4a;
font-size: 12px;
line-height: 1.5;
text-align: center;
word-break: break-word;
}
.wechat-system-message-meta {
color: #a0a7ab;
font-size: 11px;
}
.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;
}
.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 +628,8 @@ body {
width: 150px;
}
.col-content {}
.col-content {
}
.chat-toolbar {
padding: 10px;
@@ -324,7 +681,7 @@ body {
background-color: #fff;
padding: 20px;
border-radius: 8px;
width: 300px;
width: 360px;
text-align: center;
}
@@ -336,6 +693,27 @@ body {
border-radius: 4px;
}
.login-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.login-input-wrapper .login-input {
padding-right: 40px;
margin: 10px 0 10px 0;
}
.login-input-toggle {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 18px;
}
.login-btn {
background-color: #07c160;
color: #fff;
@@ -343,12 +721,79 @@ body {
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
width: 100%;
margin-top: 8px;
}
.login-btn:hover {
background-color: #06ad56;
}
.login-btn:disabled {
cursor: wait;
opacity: 0.65;
}
.login-btn-secondary {
border: 1px solid #cfd5d8;
background: #fff;
color: #30383d;
}
.login-btn-secondary:hover {
border-color: #07c160;
background: #f2fbf6;
color: #078f49;
}
.login-clear-btn {
margin-top: 10px;
border: 0;
background: transparent;
color: #8a949a;
cursor: pointer;
font-size: 12px;
}
.login-clear-btn:hover {
color: #d33b3b;
}
.login-key-status {
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #f3f5f6;
color: #59636a;
font-size: 12px;
line-height: 1.45;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
.login-key-status.success {
background: #eefaf3;
color: #078f49;
}
.login-key-status.error {
background: #fff1f0;
color: #c73737;
}
.login-key-help-link {
display: inline-block;
margin-top: 9px;
color: #1677ff;
font-size: 12px;
text-decoration: none;
}
.login-key-help-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
top: 0;
@@ -373,8 +818,389 @@ body {
overflow: auto;
}
.ai-settings-modal {
width: min(460px, 90vw);
}
.ai-settings-modal h3 {
margin: 0 0 18px;
}
.ai-filter-section {
margin-bottom: 16px;
}
.ai-filter-label {
margin-bottom: 7px;
color: #333;
font-size: 14px;
font-weight: 600;
}
.ai-date-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.ai-date-options label {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 6px;
border: 1px solid #d8dcdf;
border-radius: 6px;
background: #fff;
color: #4a5257;
cursor: pointer;
font-size: 13px;
}
.ai-date-options label.selected {
border-color: #07c160;
background: #eefaf3;
color: #078f49;
}
.ai-date-options input,
.ai-type-options input {
margin: 0;
accent-color: #07c160;
}
.ai-type-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 10px;
padding: 11px;
border: 1px solid #e1e4e6;
border-radius: 6px;
background: #f8f9fa;
}
.ai-type-options label {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: #3f474c;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.image-preview-modal {
width: min(520px, 92vw);
max-height: 88vh;
background: #f7f8fa;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18);
padding: 16px;
overflow: hidden;
}
.report-preview-frame {
border: 1px solid #d8dde3;
border-radius: 10px;
background: #eef1f4;
padding: 12px;
}
.report-preview-scroller {
max-height: calc(88vh - 120px);
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.report-preview-image {
display: block;
width: min(430px, 100%);
height: auto;
margin: 0 auto;
border-radius: 8px;
border: 1px solid #ccd3d9;
background: #fff;
}
.report-preview-actions {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.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;
box-shadow: none;
padding: 0;
}
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;
}
+313 -236
View File
@@ -1,6 +1,13 @@
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'
import {
buildGroupReportInput,
GROUP_REPORT_SYSTEM_PROMPT,
parseGroupDailyReport
} from '../utils/group-report'
interface ChatWindowProps {
contact: Contact | null
@@ -10,27 +17,41 @@ interface ChatWindowProps {
onRefreshData?: () => void
}
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
, wxid id
7
-
- (50123🔥
- (5)
- ()
- (50200
- (50)
- 72727
27, 28, 29, 30, 31, 1, 2
- 线 ------------
type SummaryDateRange = 'today' | 'yesterday' | '7days'
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
1. 使------------
2. 使
3.
4.
5.
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '最近 7 天' }
]
`
const SUMMARY_TYPE_OPTIONS: {
value: SummaryMessageType
label: string
messageTypes: string[]
}[] = [
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
{ value: 'image', label: '图片', messageTypes: ['图片'] },
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
{ value: 'video', label: '视频', messageTypes: ['视频'] },
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
]
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const endTime = Math.floor(Date.now() / 1000)
if (range === 'yesterday') {
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
}
if (range === '7days') {
return { startTime: startOfToday - 6 * 86400, endTime }
}
return { startTime: startOfToday, endTime }
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
@@ -39,16 +60,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onRefresh,
onRefreshData
}) => {
const isGroupChat = contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messagesEndRef = useRef<HTMLDivElement>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [showAvatar, setShowAvatar] = useState(false)
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true)
// AI Settings
const [showSettingsModal, setShowSettingsModal] = useState(false)
@@ -57,8 +82,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
)
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const handleSaveSettings = (): void => {
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
localStorage.setItem('ai_api_key', apiKey)
localStorage.setItem('ai_base_url', baseURL)
localStorage.setItem('ai_model', model)
@@ -66,6 +97,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
AIChat()
}
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
setSummaryMessageTypes((current) =>
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
)
}
const scrollToBottom = (): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}
@@ -74,32 +111,54 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
scrollToBottom()
}, [messages])
const startResizing = (index: number, e: React.MouseEvent): void => {
e.preventDefault()
setResizingColIndex(index)
startXRef.current = e.clientX
startWidthRef.current = colWidths[index]
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleMouseMove = (e: MouseEvent): void => {
if (resizingColIndex === null) return
const diff = e.clientX - startXRef.current
const newWidth = Math.max(50, startWidthRef.current + diff)
const closeImagePreview = (): void => {
setPreviewImage(null)
imageDragRef.current = null
}
setColWidths((prev) => {
const newCols = [...prev]
newCols[resizingColIndex] = newWidth
return newCols
const zoomImage = (delta: number): void => {
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
setImageScale(0.75)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
}
const handleViewerMouseDown = (event: React.MouseEvent): void => {
event.preventDefault()
imageDragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: imageOffset.x,
offsetY: imageOffset.y
}
}
const handleViewerMouseMove = (event: React.MouseEvent): void => {
if (!imageDragRef.current) return
const drag = imageDragRef.current
setImageOffset({
x: drag.offsetX + event.clientX - drag.x,
y: drag.offsetY + event.clientY - drag.y
})
}
const handleMouseUp = (): void => {
setResizingColIndex(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
const handleViewerMouseUp = (): void => {
imageDragRef.current = null
}
const handleExport = (days: number | 'all'): void => {
@@ -161,81 +220,52 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
document.body.removeChild(link)
}
const [summaryContent, setSummaryContent] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const AIChat = async (): Promise<void> => {
if (!messages || messages.length === 0) {
alert('当前没有消息可供总结')
if (!contact) return
if (!summaryMessageTypes.length) {
alert('请至少选择一种消息类型')
return
}
const filteredMessages = messages
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
.map((msg) => {
const { img, id, isSender, ...rest } = msg
return rest
})
const recentMessages = filteredMessages
.map((msg) => {
return `${msg.datetime} ${msg.from}: ${msg.content}`
})
.join('\n')
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
setIsLoading(true)
try {
console.log('正在请求AI...')
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
const allowedTypes = new Set(
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
(option) => option.messageTypes
)
)
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
const input = buildGroupReportInput(reportMessages, contact, isGroupChat)
console.log('🚀 ~ AIChat ~ input:', input)
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
const result = await window.api.aiChat(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt }
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
],
{ apiKey, model, baseURL }
)
if (result.success && result.data) {
console.log('AI Summary:', result.data)
setSummaryContent(result.data)
// 等待状态更新和渲染
setTimeout(() => {
textToImage()
setIsLoading(false) // 图片生成开始后停止加载
}, 500)
} else {
console.error('AI Error:', result.error)
alert(`AI 请求失败: ${result.error}`)
setIsLoading(false)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
const report = parseGroupDailyReport(result.data, input.topSpeakers, input.activeTimeline)
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
throw new Error(exported.error || '日报文件生成失败')
}
setGeneratedImage(exported.imageDataUrl)
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
} catch (error) {
console.error('AI Call Failed:', error)
alert('AI 请求发生错误')
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsLoading(false)
}
}
const textToImage = async (): Promise<void> => {
if (imageContainerRef.current) {
try {
const dataUrl = await toPng(imageContainerRef.current, {
cacheBust: true,
backgroundColor: '#ffffff',
style: {
transform: 'scale(1)'
}
})
if (dataUrl && dataUrl.length > 100) {
setGeneratedImage(dataUrl)
} else {
alert('生成图片为空')
}
} catch (err) {
console.error('Failed to generate image', err)
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
}
const handleCopyImage = async (): Promise<void> => {
if (!generatedImage) return
const result = await window.api.copyImage(generatedImage)
@@ -244,12 +274,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}
const [displayLimit, setDisplayLimit] = useState(100)
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)
@@ -257,8 +286,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
})
}, [messages, contentFilter])
const visibleMessages = filteredMessages.slice(0, displayLimit)
if (!contact) {
return (
<div className="chat-window">
@@ -274,90 +301,79 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<div className="window-controls"></div>
</div>
<div className="message-list">
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: colWidths[0], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
</th>
<th style={{ width: colWidths[1], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
</th>
<th style={{ width: colWidths[2], position: 'relative' }}>
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
</th>
<th style={{ width: colWidths[3] }}></th>
</tr>
</thead>
<tbody>
{visibleMessages.map((msg) => (
<tr key={msg.id}>
<td
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={msg.from}
>
{msg.from}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.type}
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.datetime}
</td>
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
{showAvatar && msg?.img && (
<img style={{ width: '40px', height: '40px' }} src={msg?.img}></img>
<div className="message-list wechat-message-list">
{filteredMessages.map((msg) => {
const isMine = msg.from === 'assistant'
const isSystem = msg.from === 'system' || msg.type === '系统消息'
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
)
if (isSystem) {
return (
<div key={msg.id} className="wechat-system-message-row">
<div className="wechat-system-message">{msg.content}</div>
<div className="wechat-system-message-meta">{msg.datetime}</div>
</div>
)
}
return (
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
{!isMine && showAvatar && (
<div className="message-avatar">
{avatarSrc ? (
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
) : (
(displayName || '?').charAt(0)
)}
<div
style={{
justifyContent: 'center',
alignItems: 'center',
display: !showAvatar ? 'flex' : 'block'
}}
>
{(msg.name || contact.m_nsNickName) && (
<div style={{ display: 'flex', fontSize: 18 }}>
{isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'}
<span>{isGroupChat ? (msg.name ? ':' : '') : ':'} </span>
</div>
)}
<div
style={{
fontSize: 18,
background: '#fff',
margin: 4,
padding: 4,
borderRadius: '4px'
}}
>
{msg.content}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredMessages.length > displayLimit && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<button
onClick={() => setDisplayLimit((prev) => prev + 100)}
style={{
padding: '8px 16px',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer'
}}
>
({filteredMessages.length - displayLimit} )
</button>
</div>
)}
</div>
)}
<div className="message-stack">
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
>
{isVoice && msg.sessionId ? (
<VoicePlayer
sessionId={msg.sessionId}
localId={msg.localId || 0}
createTime={msg.createTime || 0}
/>
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
<ImageBubble
imageMd5={msg.contentData.md5}
imageDatName={msg.contentData.datName}
sessionId={msg.sessionId}
onImageClick={openImagePreview}
/>
) : isRichMedia && msg.contentData ? (
<RichMessageBubble contentData={msg.contentData} />
) : (
<div className="message-text">{msg.content}</div>
)}
</div>
<div className="message-meta">
<span>{msg.datetime}</span>
<span>{msg.type}</span>
</div>
</div>
{isMine && showAvatar && (
<div className="message-avatar mine-avatar">
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
</div>
)}
</div>
)
})}
<div ref={messagesEndRef} />
</div>
@@ -399,42 +415,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</button>
</div>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: 0,
height: 0,
overflow: 'hidden',
zIndex: -1
}}
>
<div
style={{
width: '820px',
padding: '20px',
backgroundColor: '#fff',
fontSize: '22px',
color: '#000',
whiteSpace: 'pre-wrap',
fontFamily: 'sans-serif',
lineHeight: '1.5'
}}
ref={imageContainerRef}
>
{summaryContent}
</div>
</div>
{/* 加载模态框 */}
{isLoading && (
<div className="modal-overlay">
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
<div style={{ fontSize: '16px', color: '#333' }}> AI ...</div>
<div style={{ fontSize: '16px', color: '#333' }}>...</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
HTML
</div>
</div>
</div>
@@ -444,19 +432,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{generatedImage && (
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
<img
src={generatedImage}
alt="Generated Summary"
style={{ maxWidth: '100%', maxHeight: '80vh', border: '1px solid #ccc' }}
/>
<div
style={{
marginTop: '10px',
display: 'flex',
// justifyContent: 'flex-end',
gap: '10px'
}}
>
<div className="report-preview-frame">
<div className="report-preview-scroller">
<img
src={generatedImage}
alt="Generated Summary"
className="report-preview-image"
/>
</div>
</div>
<div className="report-preview-actions">
<button
onClick={handleCopyImage}
style={{
@@ -470,6 +455,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
>
📋
</button>
{reportPaths && (
<button
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
>
</button>
)}
<button
onClick={() => setGeneratedImage(null)}
style={{ padding: '5px 10px', cursor: 'pointer' }}
@@ -481,11 +474,95 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
)}
{previewImage && (
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
<div className="image-viewer-titlebar">
<div className="image-viewer-tools">
<span className="image-viewer-title"></span>
<button onClick={() => zoomImage(-0.1)} title="缩小">
</button>
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
<button onClick={() => zoomImage(0.1)} title="放大">
+
</button>
<span className="image-viewer-divider" />
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
</button>
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
</button>
<button onClick={resetImageTransform} title="重置">
</button>
</div>
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
×
</button>
</div>
<div
className="image-viewer-stage"
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
onMouseMove={handleViewerMouseMove}
onMouseUp={handleViewerMouseUp}
onMouseLeave={handleViewerMouseUp}
>
<img
src={previewImage}
alt="图片预览"
draggable={false}
style={{
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
}}
/>
</div>
</div>
</div>
)}
{/* AI Settings Modal */}
{showSettingsModal && (
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
<h3>AI </h3>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-date-options">
{SUMMARY_DATE_OPTIONS.map((option) => (
<label
key={option.value}
className={summaryDateRange === option.value ? 'selected' : ''}
>
<input
type="radio"
name="summary-date-range"
value={option.value}
checked={summaryDateRange === option.value}
onChange={() => setSummaryDateRange(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="ai-filter-section">
<div className="ai-filter-label"></div>
<div className="ai-type-options">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={summaryMessageTypes.includes(option.value)}
onChange={() => toggleSummaryMessageType(option.value)}
/>
{option.label}
</label>
))}
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>:</label>
<select
+121
View File
@@ -0,0 +1,121 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
onImageClick?: (imageUrl: string) => void
}
export function ImageBubble({
imageMd5,
imageDatName,
sessionId,
isThumb = false,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(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 element = containerRef.current
if (!element || typeof IntersectionObserver === 'undefined') {
const timer = window.setTimeout(() => void loadImage(), 0)
return () => window.clearTimeout(timer)
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
observer.disconnect()
void loadImage()
},
{ rootMargin: '400px 0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [error, imageUrl, loadImage, loading])
const handleCopy = async (event: MouseEvent): Promise<void> => {
event.stopPropagation()
if (imageUrl) {
await window.api.copyImage(imageUrl)
alert('图片已复制')
}
}
const handleClick = (): void => {
if (imageUrl) {
onImageClick?.(imageUrl)
}
}
if (loading) {
return (
<div className="image-bubble image-loading" onClick={handleClick}>
<div className="image-loading-spinner">...</div>
</div>
)
}
if (error) {
return (
<div className="image-bubble image-error" onClick={loadImage}>
<div className="image-error-text"></div>
</div>
)
}
if (!imageUrl) {
return (
<div ref={containerRef} className="image-bubble image-placeholder">
<div className="image-placeholder-icon">🖼</div>
<div className="image-placeholder-text"></div>
</div>
)
}
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
<div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
📋
</button>
</div>
</div>
)
}
@@ -0,0 +1,228 @@
import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps {
contentData: ParsedContent
}
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return <LocationBubble data={contentData} />
case 'card':
return <CardBubble data={contentData} />
case 'share':
return <ShareBubble data={contentData} />
case 'voip':
return <VoipBubble data={contentData} />
case 'sticker':
return <StickerBubble data={contentData} />
case 'quote':
return <QuoteBubble data={contentData} />
case 'system':
return <SystemBubble data={contentData} />
case 'unknown':
return (
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
)
default:
return <div className="message-text">[]</div>
}
}
function LocationBubble({
data
}: {
data: Extract<ParsedContent, { type: 'location' }>
}): JSX.Element {
const { poiname, label, lat, lng } = data
const locationText = poiname || label || '位置'
const hasCoords = lat !== 0 || lng !== 0
const handleClick = (): void => {
if (hasCoords) {
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
window.open(url, '_blank')
}
}
return (
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
<div className="location-icon">📍</div>
<div className="location-info">
<div className="location-name">{locationText}</div>
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
{hasCoords && (
<div className="location-coords">
{lat.toFixed(6)}, {lng.toFixed(6)}
</div>
)}
</div>
</div>
)
}
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
const { username, nickname } = data
const handleCopy = (e: MouseEvent): void => {
e.stopPropagation()
navigator.clipboard.writeText(username)
}
return (
<div className="card-message">
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
<div className="card-info">
<div className="card-nickname">{nickname || '未知'}</div>
<div className="card-username" onClick={handleCopy} title="点击复制">
{username}
</div>
</div>
</div>
)
}
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
const { title, des, url, appname } = data
const handleClick = (): void => {
if (url) {
window.open(url, '_blank')
}
}
const urlHost = getUrlHost(url)
return (
<div className="share-message" onClick={handleClick}>
{appname && <div className="share-appname">{appname}</div>}
<div className="share-title">{title || '链接'}</div>
{des && <div className="share-desc">{des}</div>}
{urlHost && <div className="share-url">{urlHost}</div>}
</div>
)
}
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
const { status, roomType, duration } = data
const isVideo = roomType === 1
const formatDuration = (seconds: number | undefined): string => {
if (!seconds) return ''
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
if (mins > 0) {
return `${mins}${secs}`
}
return `${secs}`
}
return (
<div className="voip-message">
<span className="voip-icon">{isVideo ? '📹' : '📞'}</span>
<span className="voip-status">
{status}
{duration ? ` ${formatDuration(duration)}` : ''}
</span>
</div>
)
}
function StickerBubble({
data
}: {
data: Extract<ParsedContent, { type: 'sticker' }>
}): JSX.Element {
const { md5, url, thumbUrl } = data
const sourceUrl = url || thumbUrl || ''
const cacheKey = md5 || sourceUrl
const [displayUrl, setDisplayUrl] = useState(() =>
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
)
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
const [error, setError] = useState(false)
useEffect(() => {
if (!cacheKey || displayUrl || error) return
let cancelled = false
window.api
.getSticker(sourceUrl, md5)
.then((result) => {
if (cancelled) return
if (result.success && result.data) {
stickerDataUrlCache.set(cacheKey, result.data)
setDisplayUrl(result.data)
setError(false)
} else {
setError(true)
}
})
.catch(() => {
if (!cancelled) setError(true)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [cacheKey, displayUrl, error, md5, sourceUrl])
if (displayUrl) {
return (
<div className="sticker-message">
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
</div>
)
}
if (loading) {
return (
<div className="sticker-message">
<div className="sticker-placeholder">...</div>
</div>
)
}
return (
<div className="sticker-message">
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
</div>
)
}
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
return (
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{quotedText}</span>
</div>
{replyText && <div className="quote-reply">{replyText}</div>}
</div>
)
}
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
return <div className="message-text">{data.content || '[系统消息]'}</div>
}
function getUrlHost(url?: string): string {
if (!url) return ''
try {
return new URL(url).hostname
} catch {
return url
}
}
+11 -1
View File
@@ -48,7 +48,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
<div className="contact-avatar">
{contact.avatar ? (
<img src={contact.avatar} alt={contact.m_nsNickName} referrerPolicy="no-referrer" />
) : (
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.m_nsNickName}</div>
</div>
@@ -98,12 +104,16 @@ export const Sidebar: React.FC<SidebarProps> = ({
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> ({groups.length})
</div>
{isGroupsExpanded && groups.map(renderContactItem)}
{isGroupsExpanded && groups.length === 0 && <div className="section-empty"></div>}
{/* 联系人部分 */}
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> ({users.length})
</div>
{isContactsExpanded && users.map(renderContactItem)}
{isContactsExpanded && users.length === 0 && (
<div className="section-empty"></div>
)}
</div>
{/* <div className="sidebar-footer">
<div className="sidebar-btn" onClick={() => window.location.reload()}>
+195
View File
@@ -0,0 +1,195 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import type { JSX } from 'react'
interface VoicePlayerProps {
sessionId: string
localId: number
createTime: number
svrId?: string | number
duration?: number
}
let globalCurrentAudio: HTMLAudioElement | null = null
let globalStopCallback: (() => void) | null = null
export function VoicePlayer({
sessionId,
localId,
createTime,
svrId
}: VoicePlayerProps): JSX.Element {
const [isPlaying, setIsPlaying] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
if (globalCurrentAudio && globalCurrentAudio !== audio) {
globalCurrentAudio.pause()
globalCurrentAudio.currentTime = 0
globalStopCallback?.()
}
globalCurrentAudio = audio
}, [])
const handlePlayPause = useCallback(async () => {
// 如果还没有音频数据,先获取
if (!audioUrl && !loading) {
setLoading(true)
setShouldAutoPlay(true)
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
try {
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
console.log('[VoicePlayer] got result:', result)
if (result.success && result.data) {
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
const byteCharacters = atob(result.data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'audio/wav' })
const blobUrl = URL.createObjectURL(blob)
console.log('[VoicePlayer] created blob URL:', blobUrl)
setAudioUrl(blobUrl)
} else {
console.log('[VoicePlayer] getVoiceData failed:', result.error)
setError(result.error || '获取语音数据失败')
setShouldAutoPlay(false)
}
} catch (e) {
console.log('[VoicePlayer] exception:', e)
setError('加载语音失败')
setShouldAutoPlay(false)
}
setLoading(false)
return
}
if (!audioRef.current) {
console.log('[VoicePlayer] no audioRef')
return
}
const audio = audioRef.current
if (isPlaying) {
audio.pause()
setIsPlaying(false)
globalStopCallback = null
} else {
stopCurrentAndPlay(audio)
audio
.play()
.then(() => {
console.log('[VoicePlayer] play() succeeded')
})
.catch((e) => {
console.log('[VoicePlayer] play() failed:', e)
})
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
audio.currentTime = 0
}
}
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
useEffect(() => {
if (!audioUrl) return
let audio = audioRef.current
if (!audio) {
audio = new Audio(audioUrl)
audioRef.current = audio
}
const audioEl = audio!
audioEl.addEventListener('loadedmetadata', () => {
setAudioDuration(audioEl.duration)
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
})
audioEl.addEventListener('ended', () => {
setIsPlaying(false)
globalStopCallback = null
})
audioEl.addEventListener('timeupdate', () => {
if (audioEl.duration && isFinite(audioEl.duration)) {
setAudioDuration(audioEl.duration)
}
})
audioEl.addEventListener('canplay', () => {
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
if (shouldAutoPlay && audioRef.current) {
setShouldAutoPlay(false)
stopCurrentAndPlay(audioRef.current)
audioRef.current.play()
setIsPlaying(true)
globalStopCallback = () => {
setIsPlaying(false)
if (audioRef.current) {
audioRef.current.currentTime = 0
}
}
}
})
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ''
audioRef.current = null
}
if (globalCurrentAudio === audioRef.current) {
globalCurrentAudio = null
globalStopCallback = null
}
}
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (loading) {
return (
<div className="voice-message voice-loading">
<span className="voice-icon"></span>
<span className="voice-loading-text">...</span>
</div>
)
}
if (error && !audioUrl) {
return (
<div className="voice-message voice-error" onClick={handlePlayPause}>
<span className="voice-icon"></span>
<span className="voice-error-text">[]</span>
</div>
)
}
return (
<div className="voice-message" onClick={handlePlayPause}>
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
<div className="voice-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</div>
<span className="voice-duration">{formatDuration(audioDuration)}</span>
</div>
)
}
+330
View File
@@ -0,0 +1,330 @@
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportHeat,
ReportImportantMessage,
ReportQuestionAnswer,
ReportQuote,
ReportResource,
ReportSpeakerRank,
ReportTopic
} from '../../../shared/group-report'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
1.
2. 使 wxid ID ID
3.
4.
5.
6.
7. JSON.parse JSON Markdown
JSON
{
"overview": "1至2句整体讨论风格与氛围",
"topics": [{
"title": "话题标题",
"timeRange": "HH:mm-HH:mm",
"heat": "高|中|低",
"participants": ["昵称"],
"summary": "话题摘要",
"conclusion": "结论或氛围",
"keywords": ["关键词"]
}],
"resources": [{"title":"资源名","description":"用途或内容","sender":"昵称"}],
"importantMessages": [{"sender":"昵称","time":"HH:mm","content":"消息摘要","note":"为什么重要"}],
"quotes": [{"messages":[{"sender":"昵称","content":"简短原话"}],"note":"点评"}],
"qa": [{"question":"问题","answer":"答案与结论","answerer":"昵称"}],
"keywords": ["关键词"]
}
topics 3 7 5 quotes 3 keywords 8 15 `
const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
const summaryContent = (message: Message): string => {
const data = message.contentData
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return `[分享] ${data.title}${data.des ? `${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}${data.quotedContent || `[引用${data.quotedType || '消息'}]`}`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `${data.duration}` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
}
export const buildGroupReportInput = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): GroupReportInput => {
const rows = messages.map((message) => ({
datetime: message.datetime,
timestamp: new Date(message.datetime).getTime(),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of rows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
for (const row of rows) {
speakerCounts.set(row.sender, (speakerCounts.get(row.sender) || 0) + 1)
if (Number.isFinite(row.timestamp)) {
const hour = new Date(row.timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (row.avatar && !avatars[row.sender]) avatars[row.sender] = row.avatar
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const timeSpan = sameDay
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}`
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: rows.length,
activeUsers: speakerCounts.size,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于当前已加载的 ${rows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容仅按类型统计。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars
}
const transcript = rows.map((row) => `${row.datetime} ${row.sender}${row.content}`).join('\n')
const prompt = `请为以下微信${isGroup ? '群聊' : '对话'}记录生成日报 JSON。
${groupName}
${dateRange}
${rows.length}
${speakerCounts.size}
${transcript}`
return { prompt, metadata, topSpeakers, activeTimeline }
}
const asObject = (value: unknown): Record<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const asString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const asName = (value: unknown): string => {
const name = asString(value)
return name && !isInternalIdentifier(name) ? name : '未命名群成员'
}
const asNumber = (value: unknown): number => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
const asStrings = (value: unknown, limit = 20): string[] =>
asArray(value).map(asString).filter(Boolean).slice(0, limit)
const normalizeHeat = (value: unknown): ReportHeat => {
const heat = asString(value)
if (heat.includes('高')) return '高'
if (heat.includes('低')) return '低'
return '中'
}
const extractJson = (raw: string): unknown => {
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
return JSON.parse(cleaned.slice(start, end + 1))
}
export const parseGroupDailyReport = (
raw: string,
topSpeakers: ReportSpeakerRank[],
activeTimeline: string
): GroupDailyReport => {
const root = asObject(extractJson(raw))
const topics: ReportTopic[] = asArray(root.topics)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
timeRange: asString(item.timeRange),
heat: normalizeHeat(item.heat),
participants: asArray(item.participants).map(asName).filter(Boolean).slice(0, 5),
summary: asString(item.summary),
conclusion: asString(item.conclusion),
keywords: asStrings(item.keywords, 8)
}
})
.filter((topic) => topic.title && topic.summary)
.slice(0, 7)
if (!topics.length) throw new Error('AI 日报中没有有效话题')
const resources: ReportResource[] = asArray(root.resources)
.map((value) => {
const item = asObject(value)
return {
title: asString(item.title),
description: asString(item.description),
sender: item.sender ? asName(item.sender) : undefined
}
})
.filter((item) => item.title && item.description)
const importantMessages: ReportImportantMessage[] = asArray(root.importantMessages)
.map((value) => {
const item = asObject(value)
return {
sender: asName(item.sender),
time: asString(item.time),
content: asString(item.content),
note: asString(item.note)
}
})
.filter((item) => item.sender && item.content)
const quotes: ReportQuote[] = asArray(root.quotes)
.map((value) => {
const item = asObject(value)
return {
messages: asArray(item.messages)
.map((messageValue) => {
const message = asObject(messageValue)
return { sender: asName(message.sender), content: asString(message.content) }
})
.filter((message) => message.sender && message.content),
note: asString(item.note)
}
})
.filter((quote) => quote.messages.length)
.slice(0, 3)
const qa: ReportQuestionAnswer[] = asArray(root.qa)
.map((value) => {
const item = asObject(value)
return {
question: asString(item.question),
answer: asString(item.answer),
answerer: item.answerer ? asName(item.answerer) : undefined
}
})
.filter((item) => item.question && item.answer)
return {
overview: asString(root.overview) || '基于已读取记录生成的群聊日报。',
topics,
resources,
importantMessages,
quotes,
qa,
analytics: {
topicHeat: topics.map((topic) => ({
topic: topic.title,
score: topic.heat === '高' ? 100 : topic.heat === '中' ? 65 : 35
})),
activeTimeline: activeTimeline || '暂无可用时间统计',
topSpeakers: topSpeakers.map((speaker) => ({
name: speaker.name,
count: Math.max(0, asNumber(speaker.count))
}))
},
keywords: asStrings(root.keywords, 15)
}
}
+92
View File
@@ -0,0 +1,92 @@
export type ReportHeat = '高' | '中' | '低'
export interface ReportTopic {
title: string
timeRange: string
heat: ReportHeat
participants: string[]
summary: string
conclusion?: string
keywords: string[]
}
export interface ReportResource {
title: string
description: string
sender?: string
}
export interface ReportImportantMessage {
sender: string
time: string
content: string
note: string
}
export interface ReportQuoteMessage {
sender: string
content: string
}
export interface ReportQuote {
messages: ReportQuoteMessage[]
note: string
}
export interface ReportQuestionAnswer {
question: string
answer: string
answerer?: string
}
export interface ReportTopicHeat {
topic: string
score: number
}
export interface ReportSpeakerRank {
name: string
count: number
}
export interface GroupDailyReport {
overview: string
topics: ReportTopic[]
resources: ReportResource[]
importantMessages: ReportImportantMessage[]
quotes: ReportQuote[]
qa: ReportQuestionAnswer[]
analytics: {
topicHeat: ReportTopicHeat[]
activeTimeline: string
topSpeakers: ReportSpeakerRank[]
}
keywords: string[]
}
export interface GroupReportMetadata {
groupName: string
reportDate: string
dateRange: string
messageCount: number
activeUsers: number
timeSpan: string
generatedAt: string
recordNote: string
footerNote: string
heroParticipants: string[]
avatars: Record<string, string | undefined>
}
export interface GroupReportExportRequest {
report: GroupDailyReport
metadata: GroupReportMetadata
}
export interface GroupReportExportResult {
success: boolean
htmlPath?: string
pngPath?: string
imageDataUrl?: string
error?: string
}
+65
View File
@@ -15,8 +15,73 @@ export interface Message {
isSender: boolean
img?: string
name?: string
contentData?: ParsedContent
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
type TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = {
type: 'location'
poiname?: string
label?: string
lat: number
lng: number
}
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
type ShareContent = {
type: 'share'
title: string
des?: string
url: string
appname?: string
typeVal?: string
}
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
type ImageContent = {
type: 'image'
md5?: string
datName?: string
aeskey?: string
encrypVer?: number
}
type StickerContent = {
type: 'sticker'
md5?: string
url?: string
thumbUrl?: string
encryptUrl?: string
aeskey?: string
}
type QuoteContent = {
type: 'quote'
title?: string
content?: string
sender?: string
quotedContent?: string
quotedSender?: string
quotedType?: string
}
type SystemContent = { type: 'system'; content: string; raw?: 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